Files
homeagent-sdk/example/bili/plugin.go
JianFeeeee b6a6168e65 chore(module)!: module 路径随托管地迁移 gitcode → github
SDK 仓已迁到 GitHub 作为权威源,go module 路径同步:
`gitcode.com/JianFeeeee/homeagent-sdk` → `github.com/JianFeeeee/homeagentsdk`

## 范围

74 个文件:71 个 Go 源码 + go.mod/go.sum + hmapdev 模板 + CI 与打包脚本。

主仓(HomeAgent)侧对应改动是 6d38a164。本仓这些文件当时**不在工作树上**
(见下),所以那次的批量替换扫不到它们——这里是补偿。

## 为何这批文件当时不在工作树上(事故与恢复)

10-08 07:58 主仓做 checkout/cherry-pick 序列时,把本仓工作树的 20 个文件
误清理了(`example/*/plugin.go` + `plg.json`,10 个示例)。

根因是**结构误判**:本仓是「一目录两仓库」结构下主仓 `third_party/` 里的
**独立 git 仓库**,而主仓 .gitignore 忽略了 `example/`,于是它在主仓
`git status` 里不可见 —— 看着像「不受跟踪的构建产物」,实际是本仓受跟踪的源码。

已全部恢复(`git checkout -- <D 文件>`,只恢复删除、不动迁移改动),
20 个文件与 HEAD 逐字节一致。

**破坏的连锁后果**:恢复出来的 `plugin.go` 仍写着旧 module 路径,
因为迁移时它们已不在工作树上。所以这次补偿是必须的,不是可选优化。

## 保留 gitcode 引用

以下位置的 `gitcode` **有意保留**,不要"顺手清掉":
- README.md / docs 里「二进制实际分发地址目前是 gitcode」的说明
- mkdocs.yml 的 gitcode 镜像链接(国内直连更快)
- `tools/hmapdev/templates.go` 里「SDK 已从 gitcode 迁到 GitHub」的注释

gitcode 仍是**只读镜像**(源码同步;release 附件暂时仍在那边)。

## 验证

- 本仓根 module:`go build ./...` + `go test ./...` 全通过
- 全部 20 个 example:`go vet` 通过(补偿前 10 个因旧 module 路径失败)
- example/qq:`go test` 通过,且能从源码 build 出可用 plugin 产物
2026-10-09 07:25:25 +08:00

356 lines
9.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"github.com/JianFeeeee/homeagentsdk/sdk"
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
proxy string
// runCancel 取消**正在跑**的 yt-dlp;runWG 等它真正退出。
//
// 为何需要:下载是分钟级操作,而 Stop() 必须能把它掐掉。
// 只 cancel 不 wait 的话内核会在插件死后立刻释放共享段,
// 而 yt-dlp 还在往插件的 stdout 写 —— 那正是内核 readLoop 挂死的成因。
runMu sync.Mutex
runCancel context.CancelFunc
runWG sync.WaitGroup
}
// trackRun 登记一次外部命令运行,返回完成时调用 untrack。
func (p *Plugin) trackRun() (context.Context, func()) {
ctx, cancel := context.WithCancel(context.Background())
p.runMu.Lock()
p.runCancel = cancel
p.runWG.Add(1)
p.runMu.Unlock()
return ctx, func() {
p.runWG.Done()
p.runMu.Lock()
p.runCancel = nil
p.runMu.Unlock()
}
}
// killRunGroup 掐掉正在跑的 yt-dlp 及其子进程(ffmpeg 等)。
func (p *Plugin) killRunGroup(pid int) {
if pid <= 0 {
return
}
// 负 pid = 整个进程组(yt-dlp 拉起的 ffmpeg 也在内)
if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil {
_ = syscall.Kill(pid, syscall.SIGKILL)
}
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
tp := p.name + "_"
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "output_dir", Default: "/tmp/bili_videos",
Type: "string", DisplayName: "下载目录",
Description: "B站视频下载后的保存目录",
Category: p.name,
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "proxy", Default: "",
Type: "string", DisplayName: "HTTP 代理",
Description: "yt-dlp 下载使用的 HTTP 代理地址(如 http://127.0.0.1:7890),留空则不设置",
Category: p.name,
})
if v, _ := s.Settings().Get("proxy"); v != nil {
if str, ok := v.(string); ok {
p.proxy = str
}
}
s.RegisterTool(tp+"video", sdk.ToolDef{
Name: tp + "video",
Description: "使用 yt-dlp 下载B站视频到本地。支持查看视频信息后再下载。下载后返回文件路径。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"url": map[string]interface{}{"type": "string", "description": "B站视频分享链接"},
"info_only": map[string]interface{}{"type": "boolean", "description": "仅获取视频信息(标题、清晰度列表),不下载"},
"format": map[string]interface{}{"type": "string", "description": "视频格式ID(如 30112=高清1080P, 30080=高清1080P, 30064=高清720P, 30032=清晰480P, 30016=流畅360P),不指定则自动选最优"},
},
"required": []string{"url"},
},
Cleaner: func(output string) string {
var r struct{ Content string }
if json.Unmarshal([]byte(output), &r) == nil && r.Content != "" {
return r.Content
}
return output
},
}, p.handleBiliVideo)
return nil
}
// Stop 取消并等待正在跑的下载。
//
// 空实现的代价(实测):yt-dlp 是分钟级操作,Stop 时它还在跑,
// 而它**继承本插件的 stdout**。内核 Kill 掉本插件后,yt-dlp 变孤儿
// 且继续持有管道写端 ⇒ 内核 readLoop 永远等不到 EOF ⇒ 整个关停挂死
// 直到 systemd 90 秒超时 SIGKILL(线上症状:只有 bili 报
// "SIGKILL 后 2s 仍未被收割",之后近 90 秒无日志)。
func (p *Plugin) Stop() error {
p.runMu.Lock()
cancel := p.runCancel
p.runMu.Unlock()
if cancel != nil {
cancel()
}
// 等它真的退出:不等的话内核会先释放共享段,孙进程仍在写 stdout。
p.runWG.Wait()
return nil
}
type ytdlpFormat struct {
FormatID string `json:"format_id"`
FormatNote string `json:"format_note"`
Ext string `json:"ext"`
Width int `json:"width"`
Height int `json:"height"`
TBR float64 `json:"tbr"`
Filesize int64 `json:"filesize"`
FilesizeApprox int64 `json:"filesize_approx"`
VCodec string `json:"vcodec"`
ACodec string `json:"acodec"`
FPS float64 `json:"fps"`
}
type ytdlpInfo struct {
Title string `json:"title"`
Duration float64 `json:"duration"`
WebpageURL string `json:"webpage_url"`
Filename string `json:"_filename"`
Formats []ytdlpFormat `json:"formats"`
}
func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, error) {
url, _ := args["url"].(string)
if url == "" {
return nil, fmt.Errorf("url is required")
}
infoOnly, _ := args["info_only"].(bool)
format, _ := args["format"].(string)
outputDir := "/tmp/bili_videos"
if p.sdk != nil {
if v, _ := p.sdk.Settings().Get("output_dir"); v != nil {
if s, ok := v.(string); ok && s != "" {
outputDir = s
}
}
}
// 安全校验:output_dir 是配置项,但避免被配成系统目录导致 yt-dlp 任意位置写。
// 禁止根/家目录本身,且规范化后必须落在明确子目录内。
outputDir = filepath.Clean(outputDir)
for _, forbidden := range []string{"/", "/etc", "/usr", "/bin", "/sbin", "/boot", "/dev", "/proc", "/sys", "/var"} {
if outputDir == forbidden {
return nil, fmt.Errorf("output_dir 不能是系统目录 %s", forbidden)
}
}
os.MkdirAll(outputDir, 0755)
var out bytes.Buffer
ytdlpArgs := []string{"--no-warnings", "--dump-json", url}
ctx, done := p.trackRun()
defer done()
// CommandContext:Stop 里的 cancel 能直接掐掉它。
// Setpgid:让 yt-dlp 自成进程组,它再拉的 ffmpeg 也在组内,
// killRunGroup 能一次带走整棵子进程树。
cmd := exec.CommandContext(ctx, "yt-dlp", ytdlpArgs...)
cmd.Stdout = &out
cmd.Stderr = &out
cmd.Env = proxyEnv(p.proxy)
setPgid(cmd)
if err := cmd.Run(); err != nil {
if ctx.Err() != nil {
return nil, fmt.Errorf("yt-dlp info 已取消(插件停止)")
}
return nil, fmt.Errorf("yt-dlp info: %w\n%s", err, strings.TrimSpace(out.String()))
}
var info ytdlpInfo
if err := json.Unmarshal(out.Bytes(), &info); err != nil {
return nil, fmt.Errorf("parse yt-dlp output: %w", err)
}
if infoOnly {
var filtered []ytdlpFormat
for _, f := range info.Formats {
if f.VCodec != "none" || f.ACodec != "none" {
filtered = append(filtered, f)
}
}
info.Formats = filtered
lines := []string{fmt.Sprintf("标题: %s", info.Title)}
if info.Duration > 0 {
lines = append(lines, fmt.Sprintf("时长: %.0f 秒", info.Duration))
}
type fmtLine struct {
ID string
Note string
Res string
Ext string
Size string
}
var seen []string
var display []fmtLine
for _, f := range info.Formats {
if f.FormatNote == "" {
continue
}
key := f.FormatNote + f.Ext
if contains(seen, key) {
continue
}
seen = append(seen, key)
res := ""
if f.Width > 0 && f.Height > 0 {
res = fmt.Sprintf("%dx%d", f.Width, f.Height)
}
sz := ""
fs := f.Filesize
if fs == 0 {
fs = f.FilesizeApprox
}
if fs > 0 {
sz = fmt.Sprintf(" (%.1f MB)", float64(fs)/1048576)
}
display = append(display, fmtLine{ID: f.FormatID, Note: f.FormatNote, Res: res, Ext: f.Ext, Size: sz})
}
if len(display) > 0 {
lines = append(lines, "清晰度列表:")
for _, d := range display {
r := d.Res
if r != "" {
r = " " + r
}
lines = append(lines, fmt.Sprintf(" [%s] %s%s | %s%s", d.ID, d.Note, r, d.Ext, d.Size))
}
}
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
}
taskDir := filepath.Join(outputDir, fmt.Sprintf("bili_%d", time.Now().UnixNano()))
if err := os.MkdirAll(taskDir, 0755); err != nil {
return nil, fmt.Errorf("mkdir task dir: %w", err)
}
dlArgs := []string{
"--no-warnings",
"--socket-timeout", "30",
"--retries", "3",
"--fragment-retries", "3",
"-o", filepath.Join(taskDir, "%(title)s.%(ext)s"),
"--no-overwrites",
}
if format != "" {
dlArgs = append(dlArgs, "-f", format)
}
dlArgs = append(dlArgs, url)
cmd2 := exec.CommandContext(ctx, "yt-dlp", dlArgs...)
cmd2.Env = proxyEnv(p.proxy)
var dlOut bytes.Buffer
cmd2.Stdout = &dlOut
cmd2.Stderr = &dlOut
setPgid(cmd2)
if err := cmd2.Run(); err != nil {
if ctx.Err() != nil {
return nil, fmt.Errorf("下载已取消(插件停止)")
}
return nil, fmt.Errorf("yt-dlp download: %w\n%s", err, strings.TrimSpace(dlOut.String()))
}
parts, _ := filepath.Glob(filepath.Join(taskDir, "*.part"))
for _, f := range parts {
os.Remove(f)
}
residuals, _ := filepath.Glob(filepath.Join(taskDir, "*.ytdl"))
for _, f := range residuals {
os.Remove(f)
}
entries, _ := os.ReadDir(taskDir)
var mainFile string
var mainSize int64
for _, e := range entries {
if e.IsDir() {
continue
}
fi, _ := e.Info()
if fi == nil {
continue
}
if fi.Size() > mainSize {
mainSize = fi.Size()
mainFile = e.Name()
}
}
if mainFile == "" {
return map[string]interface{}{
"content": "下载完成,但未找到视频文件",
}, nil
}
dlPath := filepath.Join(taskDir, mainFile)
return map[string]interface{}{
"content": fmt.Sprintf("下载完成: %s (%.1f MB)\n路径: %s", mainFile, float64(mainSize)/1048576, dlPath),
"file": dlPath,
"filename": mainFile,
}, nil
}
// setPgid 让命令自成进程组:它自己拉的子进程(yt-dlp → ffmpeg)
// 都在同一组里,kill(-pgid) 能一次带走,避免孤儿持有 stdout 管道。
func setPgid(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setpgid = true
}
func proxyEnv(proxy string) []string {
env := os.Environ()
if proxy != "" {
env = append(env, "HTTP_PROXY="+proxy, "HTTPS_PROXY="+proxy)
}
return env
}
func contains(slice []string, s string) bool {
for _, v := range slice {
if v == s {
return true
}
}
return false
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}