diff --git a/README.md b/README.md index b10efcb..9bfeb8d 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe "uid": 23682490, // 主播的 uid "danmu": true, // 是否录制弹幕 "record": true, // 是否录制直播视频 - "bitrate": 0, // 录制的直播源码率(Kbps),对应下面 qualities 里的一个档位;0 = 还没选过(按第二档) + "bitrate": 0, // 选定的档位码率(Kbps),对应下面 qualities 里的一个档位;0 = 自动(按实际档位取第二高) "qualities": [] // 开播时读到的码率档位列表,缓存下来让没开播时也能选 } ], @@ -83,7 +83,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe "mergeOutputDir": "", // 合并完成后成片移动到该文件夹,留空则不移动 "mergeBitrate": 0, // 合并成片的目标码率(Kbps),0 表示按「源片码率 + 500」自动计算 "danmuFontSize": 0, // 合并时弹幕文字的字号(像素),0 表示跟随弹幕文件;头像随字号等比缩放 - "defaultBitrate": 0, // 新添加主播的默认 bitrate,0 表示按实际档位取第二档(自上往下第二高) + "defaultBitrate": 0, // 新添加主播默认选的档位,0 = 自动(按实际档位取第二高) "webExternal": false, // 是否允许外网访问 web UI,false 时只监听本机 127.0.0.1 "authToken": "…", // web UI 管理接口的访问口令,首次启动自动生成 @@ -95,7 +95,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe "notifyLiveOn": false, // 开播时推送 "notifyLiveOff": false, // 下播时推送 "notifyRecordStart": false, // 开始录制直播视频时推送 - "notifyMergeDone": false, // 自动合并完成时推送 + "notifyMergeDone": false, // 合并完成时推送(自动合并和手动合并都会推) "meowEnabled": false, // MeoW 推送的渠道开关 "meowNickname": "", // MeoW 昵称,推送地址里的路径参数 @@ -112,7 +112,9 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe `uids`也兼容旧的纯数字数组格式,如`"uids": [23682490, 12345]`,此时弹幕和录播都默认开启、码率按实际档位取第二档。 -`bitrate`是**直播的实际档位**(如`蓝光 8M`、`高清`),在 web UI 的主播列表里下拉选择,正在录制时换档会自动重录。没选过(0)时按实际档位取**第二档**(自上往下第二高),只有一个档位就用那一档。档位要开播后读直播源列表才知道,读到以后写进`config.json`的`qualities`里缓存,所以没开播也能看和改;从来没读过档位的话,列表里还是让你直接填码率上限(Kbps)。录制始终是原画质直接保存(`-c copy`),不会重新编码,所以只能在这几个档位里挑。 +`bitrate`是**直播的实际档位**(如`蓝光 8M`、`高清`),在 web UI 的主播列表里一律用下拉选择,正在录制时换档会自动重录。下拉的第一项是`自动 · 第二高`(值 0),意思是**按这次读到的档位排序自上往下取第二高** —— 因为不一定每场都有`蓝光 8M`这类最高档,按位置排序比写死某个码率稳;只要一档就用那一档,实在读不到档位表才退回最高档。后面依次列出这次读到的实际档位,想固定用最高、最低或中间某一档直接选它;手动选过之后下拉里仍留着`自动`项,随时能改回去。 + +档位要开播后读直播源列表才知道,读到以后写进`config.json`的`qualities`里缓存,所以没开播也能看和改;从来没读过档位的行,下拉里只有`自动`一项,旁边有`档位`按钮可以去直播间接一次(主播没开播时会失败并提示)。读档位的时机是:录制开始取流时、页面上点`档位`按钮、以及发现主播在直播但还没有档位缓存时自动读一次(同一个主播 10 秒内只读一次)。录制始终是原画质直接保存(`-c copy`),不会重新编码,所以只能在这几个档位里挑。 录播原片和弹幕文件(`.ass`、`.nick.ass`、`.jsonl`)保存在保存目录下的`recordSubdir`子文件夹里(默认`record`),两者同名配对才能自动合并;合并后的成片保存在`mergeSubdir`子文件夹里(默认`merged`),留空则用默认值,两个名字不能相同。如果设置了`mergeOutputDir`,成片优先移动到那里。 @@ -201,7 +203,7 @@ acfunlive -danmuburn xxx.jsonl -video xxx.flv -ffmpeg D:\tools\ffmpeg.exe `-danmuburn`的弹幕文件支持`.jsonl`和`.ass`两种。给的是`.ass`时,如果同目录下已经有同名的非空`.jsonl`就直接用,否则按 ASS 里的`PlayResX`/`PlayResY`、字号和`\move`弹道把弹幕还原成 jsonl,并把还原结果写回弹幕文件旁边,下次再合就不用重新解析。`-out`不指定时输出到弹幕文件同目录,重名会自动加`(2)`之类的后缀,不会覆盖已有文件。ffmpeg 的路径除了`-ffmpeg`参数,也会读设置文件里的`ffmpegPath`;填相对路径时按程序所在目录解析,也可以填它所在的文件夹。 -头像会按 uid 缓存到录制文件夹下的`avatar_cache`里,圆形裁切后的直径与弹幕文字的墨迹高度一致。头像图片支持 jpg、png、gif 和静态 webp:gif(包括多帧动图)取第一帧来裁切,动画 webp 解不开、遇到就只烧文字。上游原版的`.ass`里没有头像链接(`nick.ass`只有昵称和 uid),遇到这种情况会按 uid 向 AcFun 的`userInfo`接口反查头像,查不到的只烧文字。反查走网络且有超时,所以离线环境下合并会退化成纯文字弹幕并给出警告。 +头像会按 uid 缓存到**程序所在目录**下的`avatar_cache`里(不放在录播目录,所以换保存目录、甚至换盘都不用重新下载);程序启动合并时会顺手把旧位置(以前存在录播目录下的`avatar_cache`)里已有的头像搬过来。圆形裁切后的直径与弹幕文字的墨迹高度一致。头像图片支持 jpg、png、gif 和静态 webp:gif(包括多帧动图)取第一帧来裁切,动画 webp 解不开、遇到就只烧文字。上游原版的`.ass`里没有头像链接(`nick.ass`只有昵称和 uid),遇到这种情况会按 uid 向 AcFun 的`userInfo`接口反查头像,查不到的只烧文字。反查走网络且有超时,所以离线环境下合并会退化成纯文字弹幕并给出警告。 有 Intel 核显时优先用 QSV 硬件编码(`h264_qsv`),不可用或运行失败时自动回退到`libx264`。 diff --git a/avatar_test.go b/avatar_test.go index bbff9f7..5a389e7 100644 --- a/avatar_test.go +++ b/avatar_test.go @@ -2,12 +2,16 @@ package main import ( "bytes" + "crypto/sha1" "encoding/base64" + "fmt" "image" "image/color" "image/gif" "image/jpeg" "image/png" + "os" + "path/filepath" "testing" ) @@ -109,3 +113,57 @@ func TestDecodeAvatarGifGoesThroughCircleAvatar(t *testing.T) { t.Fatal("编码出来是空的") } } + +// 头像缓存在程序所在目录,不再跟着保存目录跑 +func TestAvatarCachePathUnderExeDir(t *testing.T) { + old := exeDir + defer func() { exeDir = old }() + + exeDir = filepath.Join("C:", "app", "acfunlive") + want := filepath.Join("C:", "app", "acfunlive", avatarCacheDir) + if got := avatarCachePath(); got != want { + t.Errorf("应缓存到 %q,实际 %q", want, got) + } + + // 取不到程序目录时退回相对路径,别拼出 /avatar_cache 这种绝对路径 + exeDir = "" + if got := avatarCachePath(); got != avatarCacheDir { + t.Errorf("没有程序目录时应退回 %q,实际 %q", avatarCacheDir, got) + } +} + +// 旧版本把头像存在录播目录里,换保存目录后要能直接搬过来用,不重新下载 +func TestEnsureAvatarMigratesLegacyCache(t *testing.T) { + root := t.TempDir() + cacheDir := filepath.Join(root, "program", avatarCacheDir) + legacyDir := filepath.Join(root, "record", avatarCacheDir) + if err := os.MkdirAll(legacyDir, 0755); err != nil { + t.Fatal(err) + } + // 这个地址连不通:命中旧缓存就不该去下载(下载会重试 3 次,很慢) + url := "http://127.0.0.1:9/never.png" + sum := sha1.Sum([]byte(url)) + name := fmt.Sprintf("%d_%x.png", int64(7), sum[:6]) + old := filepath.Join(legacyDir, name) + if err := os.WriteFile(old, []byte("cached-png"), 0644); err != nil { + t.Fatal(err) + } + + got := ensureAvatar(7, url, cacheDir, legacyDir) + want := filepath.Join(cacheDir, name) + if got != want { + t.Fatalf("应搬到 %q,实际 %q", want, got) + } + data, err := os.ReadFile(want) + if err != nil || string(data) != "cached-png" { + t.Fatalf("搬过来的内容不对:%v %q", err, data) + } + if _, err := os.Stat(old); !os.IsNotExist(err) { + t.Errorf("旧位置的文件应被搬走,实际还在:%v", err) + } + + // 已经在程序目录里的直接命中,不用管旧目录 + if got := ensureAvatar(7, url, cacheDir, legacyDir); got != want { + t.Errorf("命中缓存应返回 %q,实际 %q", want, got) + } +} diff --git a/config.go b/config.go index b4a06df..378f22f 100644 --- a/config.go +++ b/config.go @@ -30,7 +30,7 @@ type streamer struct { Name string `json:"name"` // 主播名字 Danmu bool `json:"-"` // 是否录制直播弹幕 Record bool `json:"-"` // 是否录制直播视频 - Bitrate int `json:"-"` // 录制的直播源最高码率(Kbps),0 表示还没定(按实际档位取第二高) + Bitrate int `json:"-"` // 选定的档位码率(Kbps),0 = 自动(按实际档位取第二高) Qualities []qualityTier `json:"-"` // 上次探测到的码率档位,没开播时页面靠它显示下拉 } @@ -45,7 +45,7 @@ type uidSetting struct { UID int `json:"uid"` Danmu bool `json:"danmu"` Record bool `json:"record"` - Bitrate int `json:"bitrate"` // 录制的直播源最高码率(Kbps),0 表示还没定(按实际档位取第二高) + Bitrate int `json:"bitrate"` // 选定的档位码率(Kbps),0 = 自动(按实际档位取第二高) Qualities []qualityTier `json:"qualities,omitempty"` // 上次探测到的档位,缓存下来让没开播时也能在页面上选 } @@ -93,7 +93,7 @@ type configData struct { MergeBitrate int `json:"mergeBitrate"` // 合并成片的目标码率(Kbps),0 表示使用默认质量 DanmuFontSize int `json:"danmuFontSize"` // 合并时弹幕文字的字号(头像随字号等比缩放),0 表示跟随弹幕文件 - DefaultBitrate int `json:"defaultBitrate"` // 新添加主播的默认下载码率上限(Kbps),0 表示录制码率最高的直播源 + DefaultBitrate int `json:"defaultBitrate"` // 新添加主播默认选的档位码率(Kbps),0 = 自动(按实际档位取第二高) WebExternal bool `json:"webExternal"` // 是否允许外网访问 web UI,false 时只监听本机 @@ -508,8 +508,8 @@ func sameQualities(a, b []qualityTier) bool { } // 记下某个主播当前的码率档位(离线时页面靠它显示下拉)。 -// 顺手把「还没设过码率」的主播按默认档位(第二高)定下来 —— 这样默认不会一直吃满最高档。 -// 返回是否改动了配置,调用方决定要不要 saveConfig。 +// 不动用户选的码率:0 表示「自动、按实际档位取第二高」,只在真正取流时解析, +// 这样主播换了档位表也能跟着走,而不是把某个码率钉死。返回是否改动了配置。 func setStreamerQualities(uid int, tiers []qualityTier) bool { if uid <= 0 || len(tiers) == 0 { return false @@ -524,18 +524,8 @@ func setStreamerQualities(uid int, tiers []qualityTier) bool { config.UIDs[i].Qualities = tiers changed = true } - if config.UIDs[i].Bitrate == 0 { - if def := defaultQualityBitrate(tiers); def > 0 { - config.UIDs[i].Bitrate = def - changed = true - lPrintf("uid %d 还没设过下载码率,按当前直播档位默认取第二档:%s(%d Kbps)", - uid, qualityNameFor(tiers, def), def) - } - } if s, ok := streamers.crt[uid]; ok { s.Qualities = config.UIDs[i].Qualities - // 档位定下来后正在录的那场不打断,配置值从下次录制开始生效 - s.Bitrate = config.UIDs[i].Bitrate streamers.crt[uid] = s } break @@ -544,17 +534,44 @@ func setStreamerQualities(uid int, tiers []qualityTier) bool { return changed } -// 启动时把「缓存过档位、但还没定过码率」的主播按默认档位定下来 -func applyCachedDefaultQualities() bool { - changed := false - for _, u := range append(uidList(nil), config.UIDs...) { - if u.Bitrate == 0 && len(u.Qualities) > 0 { - if setStreamerQualities(u.UID, u.Qualities) { - changed = true - } +// 按选定的码率决定实际用哪个档位: +// 0(自动)取自上往下第二档(只有一个档位就用它);填了具体码率就取不超过它的最高档, +// 都比它高时退回最低档。档位列表为空返回 0,交给调用方按老规矩取最高档。 +func resolveQualityBitrate(tiers []qualityTier, bitrate int) int { + desc := sortQualitiesDesc(tiers) + if len(desc) == 0 { + return bitrate + } + if bitrate <= 0 { + return defaultQualityBitrate(desc) + } + for _, t := range desc { + if t.Bitrate <= bitrate { + return t.Bitrate } } - return changed + return desc[len(desc)-1].Bitrate +} + +// 从文件(成片或录播)名里猜是哪个主播:名字出现在文件名里、且只有一个能对上时才认, +// 认不出来返回 0。手动合并的推送靠它带上主播名,猜不准就退回通用文案。 +func guessUIDByFileName(name string) int { + if name == "" { + return 0 + } + base := filepath.Base(name) + matched := 0 + for _, s := range append(uidList(nil), config.UIDs...) { + n := nameForLog(s.UID) + if len([]rune(n)) < 2 || !strings.Contains(base, n) { + continue + } + if matched != 0 { + return 0 // 多个主播都对得上,不猜了 + } + matched = s.UID + } + return matched } // 保存 config.json,写入串行化并原子替换,避免并发写坏文件或读到写了一半的内容 diff --git a/danmuburn.go b/danmuburn.go index 20eb71d..fc81012 100644 --- a/danmuburn.go +++ b/danmuburn.go @@ -34,6 +34,15 @@ import ( // 头像缓存目录名 const avatarCacheDir = "avatar_cache" +// 头像缓存放程序所在目录:换保存目录(甚至换盘)都还能复用, +// 不用为同一批观众重新下一次。旧版本放在弹幕文件同目录,读取时会搬过来。 +func avatarCachePath() string { + if exeDir == "" { + return avatarCacheDir + } + return filepath.Join(exeDir, avatarCacheDir) +} + // 命令行 -vbitrate 指定的合成目标码率(Kbps),配合 config.MergeBitrate 使用 var burnBitrate int @@ -222,8 +231,10 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error { // 下载并圆形裁切头像 avatarPaths := make(map[int64]string) - dir := filepath.Dir(jsonlFile) - cacheDir := filepath.Join(dir, avatarCacheDir) + // 缓存放在程序所在目录,换保存目录(甚至换盘)都不用重新下载; + // 旧版本放在弹幕文件同目录,作为回退来源读一次并搬过去 + cacheDir := avatarCachePath() + legacyCache := filepath.Join(filepath.Dir(jsonlFile), avatarCacheDir) // 先去重,顺便记下每个用户自带的头像链接 urlByUID := make(map[int64]string) uids := make([]int64, 0, 64) @@ -254,7 +265,7 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error { if url == "" { continue } - if path := ensureAvatar(uid, url, cacheDir); path != "" { + if path := ensureAvatar(uid, url, cacheDir, legacyCache); path != "" { avatarPaths[uid] = path } } @@ -284,7 +295,7 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error { meta: meta, avatars: avatarPaths, bitrate: bitrate, - dir: dir, + dir: filepath.Dir(jsonlFile), } chunks := splitDanmuChunks(records, offset, videoDuration) @@ -842,11 +853,13 @@ func formatSeconds(sec float64) string { return strconv.FormatFloat(sec, 'f', 3, 64) } -// 下载头像并圆形裁切,失败自动重试,返回缓存的 PNG 路径,失败返回空字符串 -func ensureAvatar(uid int64, url, cacheDir string) string { +// 下载头像并圆形裁切,失败自动重试,返回缓存的 PNG 路径,失败返回空字符串。 +// legacyDirs 是旧版本放缓存的位置,命中就搬过来用,省得换保存目录后重新下一次。 +func ensureAvatar(uid int64, url, cacheDir string, legacyDirs ...string) string { // 文件名里带上头像链接的摘要,主播换头像后不会一直用着旧缓存 sum := sha1.Sum([]byte(url)) - path := filepath.Join(cacheDir, fmt.Sprintf("%d_%x.png", uid, sum[:6])) + name := fmt.Sprintf("%d_%x.png", uid, sum[:6]) + path := filepath.Join(cacheDir, name) if _, err := os.Stat(path); err == nil { return path } @@ -854,8 +867,33 @@ func ensureAvatar(uid int64, url, cacheDir string) string { lPrintWarnf("创建头像缓存文件夹 %s 失败,弹幕只烧录文字不带头像:%v", cacheDir, err) return "" } + // 旧位置(以前存在录播目录里)有同一个头像就直接搬过来,不重复下载 + for _, dir := range legacyDirs { + if dir == "" || dir == cacheDir { + continue + } + old := filepath.Join(dir, name) + if _, err := os.Stat(old); err != nil { + continue + } + if err := os.Rename(old, path); err == nil { + return path + } + // 跨盘改名会失败,退回复制 + if copyFile(old, path) == nil { + _ = os.Remove(old) + return path + } + } // 清掉这个用户已经失效的旧头像 - if olds, err := filepath.Glob(filepath.Join(cacheDir, strconv.FormatInt(uid, 10)+"_*.png")); err == nil { + for _, dir := range append([]string{cacheDir}, legacyDirs...) { + if dir == "" { + continue + } + olds, err := filepath.Glob(filepath.Join(dir, strconv.FormatInt(uid, 10)+"_*.png")) + if err != nil { + continue + } for _, p := range olds { if p != path { _ = os.Remove(p) diff --git a/doc/webapi.md b/doc/webapi.md index be2953d..a6431ac 100644 --- a/doc/webapi.md +++ b/doc/webapi.md @@ -80,7 +80,7 @@ web UI(`-webui`,端口看`webPort`)用的是另一套`/api/`接口,全 口令支持任意字符,包括中文——不过 HTTP 请求头只接受 ISO-8859-1,浏览器遇到非 ASCII 的口令会直接拒绝发请求(报`String contains non ISO-8859-1 code point`),所以**页面发给服务端的`X-Auth-Token`是百分号编码过的口令**(`encodeURIComponent`)。服务端两种写法都认:先拿原样比,再拿解码后的比,所以手写 curl 或者脚本按原样把中文口令放进请求头、`Authorization: Bearer`、`?token=` 里也都能通过。用脚本调这套接口时不用管编码,原样填就行。 -`GET /api/streamer/qualities?uid=` 去直播间接一次该主播的**码率档位**(返回`{"uid":…,"qualities":[{"name":"蓝光 4M","bitrate":4000},…],"bitrate":当前码率,"qualityName":当前档位名}`,`qualities`按码率从高到低)。读到的档位会缓存进`config.json`(没开播时页面靠它显示下拉),并且如果这个主播还没选过码率(0),会按**第二档**定下来。同一个主播 10 秒内只让读一次,太频繁返回 502 并给中文原因(没开播时也会返回 502)。`GET /api/status`里每个主播的`qualities`/`qualityName`就是这份缓存。 +`GET /api/streamer/qualities?uid=` 去直播间接一次该主播的**码率档位**(返回`{"uid":…,"qualities":[{"name":"蓝光 4M","bitrate":4000},…],"bitrate":选定的档位码率,"qualityBitrate":实际会用到的档位码率,"qualityName":实际会用到的档位名}`,`qualities`按码率从高到低)。读到的档位会缓存进`config.json`(没开播时页面靠它显示下拉),**但不会改动用户选的档位**:`bitrate`为 0 表示`自动`(按档位排序取第二高),只在取流时解析,所以主播换了档位表也能跟着走。同一个主播 10 秒内只让读一次,太频繁返回 502 并给中文原因(没开播时也会返回 502,且没开播时服务端本来就要重试三次,页面上会先给一句`正在读取码率档位…`)。`GET /api/status`里每个主播的`qualities`是这份缓存,`qualityBitrate`/`qualityName`是根据当前选择解析出来的结果(`bitrate`为 0 时就是第二高的那一档)。 跟手动合并有关的是下面三个: `GET /api/merge/danmu?video=<视频文件名或路径>` 按视频名找同目录下的同名弹幕文件,给页面「选好视频自动匹配弹幕」用。依次试`.jsonl`、`.ass`、`.nick.ass`,同目录没有就拿文件名去录播目录再找一次;返回`{"danmu":"…"}`,找不到时是空字符串。值里录播目录/成片目录下的文件只写文件名,别处的写绝对路径,和`浏览`弹窗的填法一致。 @@ -97,7 +97,7 @@ web UI(`-webui`,端口看`webPort`)用的是另一套`/api/`接口,全 返回分段列表(名字、体积、时长、是否正在写入)、总时长、弹幕条数、弹幕时间范围、要用的弹幕文件和来源(`jsonl 弹幕数据` / `同名的 jsonl 弹幕数据` / `由 xxx.ass 还原`)、推算出的偏移和原因、最终输出文件名、以及提示(`warnings`)。参数不合法一律 400,并在响应体里给中文原因。 -`POST /api/merge` 真正开始合并,请求体和上面一样,另外可以带`outName`指定输出文件名(不合法会被清理成安全的文件名,重名自动加后缀)。成功后立刻返回`{"outName":"…"}`,合并在后台跑。已经有一个手动合并任务在跑时返回 409。 +`POST /api/merge` 真正开始合并,请求体和上面一样,另外可以带`outName`指定输出文件名(不合法会被清理成安全的文件名,重名自动加后缀)。成功后立刻返回`{"outName":"…"}`,合并在后台跑。已经有一个手动合并任务在跑时返回 409。**合并完成也会触发一次推送**(和自动合并同一个`mergeDone`事件):手动合并没有 uid,会拿成片或视频的文件名去对已有的主播名,只有一个能对上时才在文案里带上主播名,对不上就用`手动合并完成`。 合并状态通过`GET /api/status`查看:`mergeBusy`(是否在跑)、`mergeOutName`(当前成片名)、`mergeErr`(失败原因)、`mergeDoneAt`(上次完成时间)。合并期间成片会出现在`GET /api/files`里且`inUse`为 true,此时`POST /api/file/remove`会返回 409 拒绝删除。 diff --git a/main.go b/main.go index 7ad43d2..e74e7c2 100644 --- a/main.go +++ b/main.go @@ -106,10 +106,6 @@ func initialize() { loadConfig() checkConfig() - // 缓存过档位、但还没定过码率的主播,启动时按默认档位(第二高)定下来 - if applyCachedDefaultQualities() { - saveConfig() - } // 上次在合并途中被关掉的话,录播目录和成片目录里会留下临时文件,启动时清一次 cleanLeftoverTempFiles() ensureAuthToken() diff --git a/manualmerge.go b/manualmerge.go index 54cb9b6..e7b380f 100644 --- a/manualmerge.go +++ b/manualmerge.go @@ -538,6 +538,16 @@ func runManualMerge(plan mergePlan) error { } lPrintln("手动合并完成:" + plan.out) + // 通知:手动合并没有 uid,能从文件名对上哪个主播就带上名字,对不上就用通用文案 + uid := guessUIDByFileName(plan.out) + if uid == 0 { + uid = guessUIDByFileName(video) + } + text := "手动合并完成\n" + plan.out + if uid > 0 { + text = longID(uid) + " 的录播和弹幕(手动合并)完成\n" + plan.out + } + sendNotify(notifyEventMergeDone, uid, nameForLog(uid), "", "", plan.out, text) return nil } diff --git a/notify_test.go b/notify_test.go index ae79c9e..fc9eaa4 100644 --- a/notify_test.go +++ b/notify_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "os" + "os/exec" "path/filepath" "strings" "sync" @@ -592,3 +593,104 @@ func TestNotifyTestHandler(t *testing.T) { t.Errorf("非法昵称应该 400,实际 %d", code) } } + +// 手动合并完成也要推送 —— 以前只有自动合并会推,所以在页面上手动合并时 MeoW 一直没动静 +func TestManualMergeSendsMergeDoneNotify(t *testing.T) { + setupMergeTest(t) + ffmpeg := getFFmpeg() + if ffmpeg == "" { + t.Skip("没有 ffmpeg") + } + + savedConfig := config + defer func() { config = savedConfig }() + + var mu sync.Mutex + var hooks []notifyPayload + webhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var p notifyPayload + _ = json.NewDecoder(r.Body).Decode(&p) + mu.Lock() + hooks = append(hooks, p) + mu.Unlock() + _, _ = w.Write([]byte("ok")) + })) + defer webhook.Close() + + var meowQuery []string + withMeowServer(t, func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + meowQuery = append(meowQuery, r.URL.RawQuery) + mu.Unlock() + _, _ = w.Write([]byte(`{"status":200,"message":"推送成功"}`)) + }) + + // 只开合并完成这一个事件,两个渠道都开 + config.NotifyURL = webhook.URL + config.NotifyLiveOn = false + config.NotifyMergeDone = true + config.MeowEnabled = true + config.MeowNickname = "小小怪" + config.MeowMsgType = "text" + config.UIDs = uidList{{UID: 999, Danmu: true, Record: true}} + // 文件名里带主播名,手动合并靠它认人 + streamers.Lock() + streamers.crt = map[int]streamer{999: {UID: 999, Name: "测试主播"}} + streamers.Unlock() + + name := "2026-09-25 01-00-00 测试主播 标题" + video := filepath.Join(recordSaveDir(), name+".mp4") + if out, err := exec.Command(ffmpeg, "-v", "error", "-y", + "-f", "lavfi", "-i", "color=c=black:size=320x180:rate=15", "-t", "3", + "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", video).CombinedOutput(); err != nil { + t.Fatalf("生成测试视频失败:%v\n%s", err, out) + } + lines := []string{`{"playResX":320,"playResY":180,"fontSize":16,"startTime":0}`, + `{"t":0.5,"uid":101,"name":"甲","content":"手动合并推送测试"}`, + `{"t":1.5,"uid":102,"name":"乙","content":"第二条"}`} + writeTestFile(t, filepath.Join(recordSaveDir(), name+".jsonl"), strings.Join(lines, "\n")+"\n") + + plan, err := buildMergePlan(mergeRequest{Videos: []string{name + ".mp4"}, Danmu: name + ".jsonl"}) + if err != nil { + t.Fatalf("检查失败:%v", err) + } + if err := runManualMerge(plan); err != nil { + t.Fatalf("手动合并失败:%v", err) + } + + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + mu.Lock() + nh, nm := len(hooks), len(meowQuery) + mu.Unlock() + if nh > 0 && nm > 0 { + break + } + time.Sleep(30 * time.Millisecond) + } + + mu.Lock() + defer mu.Unlock() + if len(hooks) != 1 { + t.Fatalf("QQ 机器人应收到 1 条推送,实际 %d 条", len(hooks)) + } + p := hooks[0] + if p.Event != notifyEventMergeDone { + t.Errorf("事件名应为 %s,实际 %s", notifyEventMergeDone, p.Event) + } + if p.UID != 999 || p.Name != "测试主播" { + t.Errorf("应从文件名认出主播 999/测试主播,实际 %d/%q", p.UID, p.Name) + } + if p.File != plan.out { + t.Errorf("推送里的文件应为成片 %q,实际 %q", plan.out, p.File) + } + if !strings.Contains(p.Text, "手动合并") || !strings.Contains(p.Text, plan.out) { + t.Errorf("推送文案不对:%q", p.Text) + } + if len(meowQuery) != 1 { + t.Fatalf("MeoW 应收到 1 条推送,实际 %d 条", len(meowQuery)) + } + if !strings.Contains(meowQuery[0], "msgType=text") { + t.Errorf("MeoW 的 msgType 应走 query 参数,实际 %q", meowQuery[0]) + } +} diff --git a/qualities_test.go b/qualities_test.go index 4e14949..393e3ea 100644 --- a/qualities_test.go +++ b/qualities_test.go @@ -75,7 +75,8 @@ func TestSameQualities(t *testing.T) { } } -// 记录档位时顺便把「还没设过码率」的主播按默认档位定下来 +// 记录档位时只缓存档位表,不动用户选的码率: +// 0 表示「自动、按实际档位取第二高」,主播换了档位表要能跟着走 func TestSetStreamerQualities(t *testing.T) { savedConfig := config savedStreamers := streamers.crt @@ -85,8 +86,8 @@ func TestSetStreamerQualities(t *testing.T) { }() config.UIDs = uidList{ - {UID: 1, Danmu: true, Record: true, Bitrate: 0}, // 没设过 → 应被定成第二档 - {UID: 2, Danmu: true, Record: true, Bitrate: 1000}, // 手动设过 → 不动 + {UID: 1, Danmu: true, Record: true, Bitrate: 0}, // 自动 + {UID: 2, Danmu: true, Record: true, Bitrate: 1000}, // 手动指定过 } streamers.crt = map[int]streamer{ 1: {UID: 1, Bitrate: 0}, @@ -96,14 +97,17 @@ func TestSetStreamerQualities(t *testing.T) { if !setStreamerQualities(1, tiersForTest()) { t.Error("第一次记录档位应该算改动了配置") } - if config.UIDs[0].Bitrate != 4000 { - t.Errorf("uid 1 没设过码率,应被定成第二档 4000,实际 %d", config.UIDs[0].Bitrate) + if config.UIDs[0].Bitrate != 0 { + t.Errorf("自动的主播不该被写死成具体码率,实际 %d", config.UIDs[0].Bitrate) } - if streamers.crt[1].Bitrate != 4000 || len(streamers.crt[1].Qualities) != 4 { - t.Errorf("运行时的主播数据没同步:%+v", streamers.crt[1]) + if len(config.UIDs[0].Qualities) != 4 || len(streamers.crt[1].Qualities) != 4 { + t.Errorf("档位没缓存到配置或运行时数据:%+v / %+v", config.UIDs[0], streamers.crt[1]) + } + if streamers.crt[1].Bitrate != 0 { + t.Errorf("运行时数据的码率不该被改动,实际 %d", streamers.crt[1].Bitrate) } - // 手动设过的不能被默认档位覆盖 + // 手动设过的同样不动 setStreamerQualities(2, tiersForTest()) if config.UIDs[1].Bitrate != 1000 { t.Errorf("uid 2 手动设过 1000,不该被改成 %d", config.UIDs[1].Bitrate) @@ -119,8 +123,43 @@ func TestSetStreamerQualities(t *testing.T) { } } -// 启动时按缓存里的档位把默认码率补齐 -func TestApplyCachedDefaultQualities(t *testing.T) { +// 实际用哪个档位:0 = 自动(第二高),填了具体码率 = 不超过它的最高档, +// 都比它高时退回最低档 +func TestResolveQualityBitrate(t *testing.T) { + cases := []struct { + name string + bitrate int + want int + }{ + {"自动取第二高", 0, 4000}, + {"指定最高档", 8000, 8000}, + {"指定第二高", 4000, 4000}, + {"指定最低档", 1000, 1000}, + {"填了中间值取不超过它的最高档", 3000, 2000}, + {"比所有档位都高就取最高档", 99999, 8000}, + {"比所有档位都低就退回最低档", 200, 1000}, + } + for _, c := range cases { + if got := resolveQualityBitrate(tiersForTest(), c.bitrate); got != c.want { + t.Errorf("%s:应为 %d,实际 %d", c.name, c.want, got) + } + } + // 没有档位表时保持原样(取流逻辑会退回最高档) + if got := resolveQualityBitrate(nil, 0); got != 0 { + t.Errorf("没有档位表时应返回 0,实际 %d", got) + } + if got := resolveQualityBitrate(nil, 2000); got != 2000 { + t.Errorf("没有档位表时应原样返回,实际 %d", got) + } + // 只有一档时自动档就是它 + one := []qualityTier{{"高清", 2000}} + if got := resolveQualityBitrate(one, 0); got != 2000 { + t.Errorf("只有一档时自动应为 2000,实际 %d", got) + } +} + +// 手动合并的通知靠文件名猜主播:只有一个名字对得上才认 +func TestGuessUIDByFileName(t *testing.T) { savedConfig := config savedStreamers := streamers.crt defer func() { @@ -128,24 +167,31 @@ func TestApplyCachedDefaultQualities(t *testing.T) { streamers.crt = savedStreamers }() - config.UIDs = uidList{ - {UID: 1, Bitrate: 0, Qualities: tiersForTest()}, // 有缓存、没定过 → 补上 - {UID: 2, Bitrate: 2000, Qualities: tiersForTest()}, // 已经有值 → 不动 - {UID: 3, Bitrate: 0}, // 没缓存 → 不动 + config.UIDs = uidList{{UID: 1}, {UID: 2}, {UID: 3}} + streamers.crt = map[int]streamer{ + 1: {UID: 1, Name: "AC娘本体"}, + 2: {UID: 2, Name: "随便唠会"}, + 3: {UID: 3, Name: "阿"}, } - streamers.crt = map[int]streamer{1: {UID: 1}, 2: {UID: 2, Bitrate: 2000}, 3: {UID: 3}} - if !applyCachedDefaultQualities() { - t.Fatal("有需要补的主播时应算改动") + cases := []struct { + file string + want int + }{ + {"2024-10-14 00-33-32 AC娘本体 嘿嘿 醒了_弹幕合并.mp4", 1}, + {"AC娘本体.mp4", 1}, + {"2024-10-14 00-33-32 随便唠会 摸鱼.mp4", 2}, + // 两个名字都在文件名里,猜不准就不猜 + {"AC娘本体 和 随便唠会 联动.mp4", 0}, + {"完全不认识的主播.mp4", 0}, + // 名字只有一个字的不参与匹配,太容易误伤 + {"阿 的录播.mp4", 0}, + {"", 0}, } - if config.UIDs[0].Bitrate != 4000 { - t.Errorf("uid 1 应补成 4000,实际 %d", config.UIDs[0].Bitrate) - } - if config.UIDs[1].Bitrate != 2000 { - t.Errorf("uid 2 不该被改动,实际 %d", config.UIDs[1].Bitrate) - } - if config.UIDs[2].Bitrate != 0 { - t.Errorf("uid 3 没有档位缓存,不该被改动,实际 %d", config.UIDs[2].Bitrate) + for _, c := range cases { + if got := guessUIDByFileName(c.file); got != c.want { + t.Errorf("%q 应猜出 %d,实际 %d", c.file, c.want, got) + } } } diff --git a/record.go b/record.go index b8cfb9e..776b21e 100644 --- a/record.go +++ b/record.go @@ -121,7 +121,8 @@ func fetchStreamQualities(uid int) ([]qualityTier, error) { return streamTiers(info.StreamList), nil } -// 获取 uid 的直播源(flv),bitrate 为 0 时取码率最高的源,否则取码率不超过 bitrate(Kbps)的最高档 +// 获取 uid 的直播源(flv)。bitrate 是页面上选的档位码率,0 表示自动: +// 按这次拿到的实际档位自上往下取第二高(只有一个档位就用它,拿不到档位表退回最高档) func getStreamURL(uid, bitrate int) (url string, quality string, err error) { sInfo, err := newStreamInfo(uid) if err != nil { @@ -131,26 +132,19 @@ func getStreamURL(uid, bitrate int) (url string, quality string, err error) { return "", "", fmt.Errorf("uid 为%d的主播没有可用的直播源", uid) } // 顺手把码率档位记下来:页面上要用它做下拉,没开播时靠缓存显示 - if tiers := streamTiers(sInfo.StreamList); len(tiers) > 0 { + var tiers []qualityTier + if tiers = streamTiers(sInfo.StreamList); len(tiers) > 0 { if setStreamerQualities(uid, tiers) { saveConfig() } - // 还没设过码率的(0)刚被默认档位定下来,这里跟着用它,别又回到最高档 - if bitrate == 0 { - for _, u := range config.UIDs { - if u.UID == uid { - bitrate = u.Bitrate - break - } - } - } } - // StreamList 按码率升序排列 + want := resolveQualityBitrate(tiers, bitrate) + // StreamList 按码率升序排列,从高往低找选中的那一档 st := sInfo.StreamList[len(sInfo.StreamList)-1] - if bitrate > 0 { + if want > 0 { st = sInfo.StreamList[0] for i := len(sInfo.StreamList) - 1; i >= 0; i-- { - if sInfo.StreamList[i].Bitrate <= bitrate { + if sInfo.StreamList[i].Bitrate <= want { st = sInfo.StreamList[i] break } diff --git a/webui.go b/webui.go index cf3763c..424fc96 100644 --- a/webui.go +++ b/webui.go @@ -98,8 +98,7 @@ const webuiHTML = ` input[readonly]{background:var(--soft2);color:var(--sub)} #mrgInfo{white-space:pre-wrap;margin-top:10px;font-size:12.5px;line-height:1.7;color:var(--text);background:var(--soft2);border-radius:var(--radius-s);padding:10px 12px} #mrgInfo:empty{display:none} - input.br{width:92px;text-align:center;padding:6px 8px} - select.brsel{padding:6px 6px;max-width:150px} + select.brsel{padding:6px;max-width:190px} button.mini{padding:4px 6px;font-size:11.5px} /* 开关 */ .sws{display:flex;flex-wrap:wrap;gap:10px 22px} @@ -241,7 +240,7 @@ const webuiHTML = `

主播列表

勾选 = 开启对应录制,改完立即生效
-
码率这里选的是直播的实际档位(如蓝光4M / 高清),开播后自动读取,也能点「档位」按钮手动读一次;没手动选过的默认用第二档(自上往下第二高)。正在录制时换档会自动重录。没读过档位前可以直接填码率上限(Kbps,0 = 用默认档位)
+
码率这里选的是直播的实际档位(如蓝光 4M / 高清),开播后自动读取,也能点每行的「档位」按钮立刻读一次。默认「自动 · 第二高」= 按这次读到档位排序自上往下取第二高(不一定有蓝光 8M,所以只按实际档位排),也可以直接选最高、最低或中间某一档。正在录制时换档会自动重录。
@@ -275,7 +274,7 @@ const webuiHTML = `
-
+
@@ -689,23 +688,29 @@ async function refresh(){ if(!badges) badges = s.live ? '直播中' : '离线'; var title = esc(s.title); var qs = s.qualities || []; - var brCell; - if(qs.length){ - // 有档位列表就用下拉,值是档位的码率;当前值匹配不上档位时补一个「当前设置」 - var opts = ''; - var matched = false; - qs.forEach(function(q){ - var sel = (q.bitrate === s.bitrate); - if(sel) matched = true; - opts += ''; - }); - if(!matched) opts = '' + opts; - brCell = ''; - }else{ - brCell = '' - + ''; + // 档位一律用下拉:第一项「自动 · 第二高」(跟着实际档位排序走,值 0), + // 后面是这次读到的实际档位。手动选过的档位就是选中项,随时能选回「自动」。 + var autoLabel = '自动 · 第二高'; + if(!s.bitrate){ + autoLabel += s.qualityName ? '(当前 = ' + esc(s.qualityName) + ')' : (qs.length ? '' : '(开播后才有)'); } + var opts = ''; + var matched = false; + qs.forEach(function(q){ + if(q.bitrate === s.bitrate) matched = true; + opts += ''; + }); + // 指定过的档位现在这一场没有(比如主播换了档位表)时补一项,免得选中项空着 + if(s.bitrate && !matched){ + opts += ''; + } + // 还没读到档位表时下拉里只有「自动」,旁边给个按钮去直播间接一次 + var tip = qs.length ? '录制档位,正在录制时换档会自动重录;「自动 · 第二高」跟着实际档位排序走' + : '还没读到档位表(开播后才拿得到),点右边按钮去录一次'; + var brCell = '' + + ''; return '
' + 'uid ' + s.uid + '' + '' + (esc(s.name) || "uid " + s.uid) + '' @@ -721,20 +726,10 @@ async function refresh(){ }).join(""); var list = document.getElementById("st"); var sig = JSON.stringify(st.streamers); - // 数据没变化不重建;正在列表里输入时也跳过,避免把没提交的输入清掉 + // 数据没变化不重建;正在列表里操作时也跳过 if(sig !== stSig && !list.contains(document.activeElement)){ stSig = sig; - // 重建前把用户改过、还没提交的码率记下来,建完再抄回去(填错的不用留) - var keepBr = {}; - Array.prototype.forEach.call(list.querySelectorAll("input.br"), function(el){ - var v = parseInt(el.value.trim(), 10); - if(el.dataset.dirty === "1" && !isNaN(v) && v >= 0) keepBr[el.dataset.uid] = el.value; - }); list.innerHTML = rows || '
列表为空,在上面填 uid 添加
'; - Object.keys(keepBr).forEach(function(uid){ - var el = list.querySelector('input.br[data-uid="' + uid + '"]'); - if(el){ el.value = keepBr[uid]; el.dataset.dirty = "1"; } - }); } // 开播了但还没有档位缓存的,自动去读一次,让码率下拉能用 autoLoadQualities(st.streamers); @@ -1246,13 +1241,6 @@ async function delUid(uid){ if(!confirm("确定删除 uid " + uid + " 并停止其录制?")) return; post("/api/streamer/remove", {uid: uid}); } -// 码率填错时把框里的内容恢复成服务端记录的值,不能干等下一次列表重建 -function restoreBr(el){ - var uid = parseInt(el.dataset.uid, 10); - var cur = (lastSt && lastSt.streamers ? lastSt.streamers : []).filter(function(s){ return s.uid === uid; })[0]; - delete el.dataset.dirty; - el.value = cur ? (cur.bitrate || 0) : 0; -} document.addEventListener("change", async function(e){ var t = e.target; if(t.matches('input[type=checkbox][data-uid]')){ @@ -1260,10 +1248,8 @@ document.addEventListener("change", async function(e){ var row = t.closest(".row"); var danmu = row.querySelector('input[data-k=danmu]').checked; var record = row.querySelector('input[data-k=record]').checked; - var brEl = row.querySelector('input.br') || row.querySelector('select.brsel'); - var bitrate = parseInt(brEl.value, 10) || 0; - // 勾选时会把同一行的码率一起提交,提交成功后就不算「未提交的输入」了 - if(await post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: bitrate}) && brEl.dataset) delete brEl.dataset.dirty; + var bitrate = parseInt(row.querySelector('select.brsel').value, 10) || 0; + post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: bitrate}); } if(t.matches('select.brsel')){ // 档位下拉是选完立即生效的(正在录制时会自动换档重录) @@ -1274,22 +1260,14 @@ document.addEventListener("change", async function(e){ var record3 = row3.querySelector('input[data-k=record]').checked; await post("/api/streamer", {uid: uid3, danmu: danmu3, record: record3, bitrate: v3}); } - if(t.matches('input.br')){ - var uid2 = parseInt(t.dataset.uid, 10); - var row2 = t.closest(".row"); - var v = parseInt(t.value.trim(), 10); - if(isNaN(v) || v < 0){ showMsg(false, "码率必须是大于等于 0 的数字"); restoreBr(t); return; } - var danmu2 = row2.querySelector('input[data-k=danmu]').checked; - var record2 = row2.querySelector('input[data-k=record]').checked; - // 提交成功才能把「未提交」标记去掉,列表重建时才不会把它当成用户还在填 - if(await post("/api/streamer", {uid: uid2, danmu: danmu2, record: record2, bitrate: v})) delete t.dataset.dirty; - } }); -// 去直播间接一次码率档位;拿到后列表会显示成档位下拉,没设过码率的会被服务端按第二档定下来 +// 去直播间接一次码率档位;拿到后下拉里就列出实际档位(0 = 自动第二高) var qualitiesTried = {}; -async function loadQualities(uid){ +async function loadQualities(uid, quiet){ if(qualitiesTried[uid]) return; qualitiesTried[uid] = true; + // 主播没开播时服务端要重试三次才放弃(约 5 秒),先给个反馈,别让人以为按钮没反应 + if(!quiet) showMsg(true, "正在读取码率档位…"); var r = null; try{ r = await api("/api/streamer/qualities?uid=" + uid); }catch(e){} if(!r || !r.ok){ @@ -1302,10 +1280,10 @@ async function loadQualities(uid){ showMsg(true, "已读取 " + (d.qualities || []).length + " 个码率档位"); await refresh(); } -// 开播但还没档位缓存时自动读一次,让下拉直接能用 +// 开播但还没档位缓存时自动读一次,让下拉直接能用(这种必定成功,不用提示「正在读取」) function autoLoadQualities(list){ (list || []).forEach(function(s){ - if(s.live && !(s.qualities || []).length) loadQualities(s.uid); + if(s.live && !(s.qualities || []).length) loadQualities(s.uid, true); }); } // 回到地址栏或上次看的那个分区 @@ -1321,18 +1299,19 @@ document.getElementById("tokenInput").addEventListener("keydown", function(e){ i // web UI 的主播状态 type webStreamer struct { - UID int `json:"uid"` - Name string `json:"name"` - Live bool `json:"live"` - Danmu bool `json:"danmu"` // 弹幕录制开关 - Record bool `json:"record"` // 视频录制开关 - Bitrate int `json:"bitrate"` // 录制的直播源码率(Kbps),对应档位里的一个档 - Qualities []qualityTier `json:"qualities"` // 可选档位(按码率从高到低),没探测到时为空 - QualityName string `json:"qualityName"` // 当前码率对应的档位名 - DanmuActive bool `json:"danmuActive"` // 是否正在录制弹幕 - RecordActive bool `json:"recordActive"` // 是否正在录制视频 - Title string `json:"title"` - URL string `json:"url"` + UID int `json:"uid"` + Name string `json:"name"` + Live bool `json:"live"` + Danmu bool `json:"danmu"` // 弹幕录制开关 + Record bool `json:"record"` // 视频录制开关 + Bitrate int `json:"bitrate"` // 选定的档位码率(Kbps),0 = 自动(实际档位第二高) + Qualities []qualityTier `json:"qualities"` // 可选档位(按码率从高到低),没探测到时为空 + QualityBitrate int `json:"qualityBitrate"` // 实际会用到的档位码率(自动时就是第二高的那个) + QualityName string `json:"qualityName"` // 实际会用到的档位名 + DanmuActive bool `json:"danmuActive"` // 是否正在录制弹幕 + RecordActive bool `json:"recordActive"` // 是否正在录制视频 + Title string `json:"title"` + URL string `json:"url"` } // web UI 的状态数据 @@ -1440,7 +1419,8 @@ func statusHandler(w http.ResponseWriter, r *http.Request) { } ws := webStreamer{UID: s.UID, Name: s.Name, Live: live, Title: title, URL: s.getURL(), Danmu: s.Danmu, Record: s.Record, Bitrate: s.Bitrate} ws.Qualities = sortQualitiesDesc(s.Qualities) - ws.QualityName = qualityNameFor(s.Qualities, s.Bitrate) + ws.QualityBitrate = resolveQualityBitrate(s.Qualities, s.Bitrate) + ws.QualityName = qualityNameFor(s.Qualities, ws.QualityBitrate) if infoList, ok := getLiveInfoByUID(s.UID); ok { for _, info := range infoList { if info.isDanmu { @@ -1692,11 +1672,13 @@ func streamerQualitiesHandler(w http.ResponseWriter, r *http.Request) { } streamers.RUnlock() w.Header().Set("Content-Type", "application/json; charset=utf-8") + used := resolveQualityBitrate(tiers, bitrate) _ = json.NewEncoder(w).Encode(map[string]any{ - "uid": uid, - "qualities": sortQualitiesDesc(tiers), - "bitrate": bitrate, - "qualityName": qualityNameFor(tiers, bitrate), + "uid": uid, + "qualities": sortQualitiesDesc(tiers), + "bitrate": bitrate, + "qualityBitrate": used, + "qualityName": qualityNameFor(tiers, used), }) }