mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-03 15:53:56 +00:00
chore(vendor): 主仓不再跟踪 SDK 示例代码(决策 A)
## 背景 `.gitignore` 第 26-45 行早已写明「外部插件与工具链维护在独立 SDK 仓 (决策 sdk_repo_only),本仓经 go.mod 的 replace 引用」,并忽略了 example/、tools/、docs/、site_build/、package/、scripts/。 但有 29 个文件**早于该规则**被跟踪,靠「已跟踪文件不受 .gitignore 影响」留着,注释还特意写了「这是有意的,不要『修』」。 本轮修 example/bili 时踩到了:那 87 行改动先落在主仓、再手动同步到 SDK 仓 —— 同一份代码两个仓各改一遍,正是这个遗留结构的成本。 ## 核实:主仓到底需要什么 - 主仓 Go 代码只 import 两个包:`homeagent-sdk/sdk`(插件入口)、 `homeagent-sdk/meta`(版本号) - `remotedevice/` 是 C 库,主仓 C 侧明确「不链接任何外部库」, 只有注释里提到它 - `tools/hmapdev/yaegi` 的 import 出现在 **SDK 仓自己的文件之间** (yaegi/interp.go → yaegi/mocksdk),不是主仓依赖; 且 tools/ 本就在 .gitignore 里,从未被跟踪 所以 29 个文件全部可以删。实际仓库负担也只有 42 个文件 / 0.6MB (工作树里那 971MB 绝大部分是未跟踪的构建产物,不是仓库体积)。 ## 改动 - 删 21 个 example/ 文件(10 个示例的 plg.json + plugin.go + qq/plugin_test.go) - 删 8 个 remotedevice/ C 文件 - 工作树里一并清掉(不受跟踪的构建产物顺带回收) 构建与全量测试均通过。 ## 判据:3 条 + 变异(internal/meta/vendored_sdk_test.go) - TestVendoredSDKHasNoExampleOrRemotedevice:防止示例代码被重新提交进来 - TestVendoredSDKKeepsRequiredPackages:**反向**保护,防止为省事把 sdk/ 与 meta/ 也删掉(上一条只防"多了",删过头要靠这条) - TestGoModStillReplacesSDKToVendoredPath:决策 A 依赖 replace 指向 vendored 路径 ★ 判据自己踩了两个坑,都靠实跑抓出来: 1. `git ls-files` 的路径参数**相对当前目录**解析,而测试跑在 internal/meta/ 下 ⇒ 就地执行返回空,表现为「必需包全都不在」的假红。 改用 `git -C <仓库根>`。 2. 把 tools/hmapdev/yaegi 当成主仓依赖写进必需清单 ⇒ 又一次假红。 根因是把 SDK 内部的引用误当成主仓依赖(它本就在 .gitignore 里)。 变异验证:塞一个 example 文件进版本控制 → 判红。
This commit is contained in:
19
third_party/homeagent-sdk/example/bili/plg.json
vendored
19
third_party/homeagent-sdk/example/bili/plg.json
vendored
@ -1,19 +0,0 @@
|
||||
{
|
||||
"name": "bili",
|
||||
"name_zh": "B站视频下载",
|
||||
"name_en": "Bilibili Video Downloader",
|
||||
"version": "1.2.0",
|
||||
"description": "B站视频下载工具,基于 yt-dlp 引擎。支持查看视频清晰度列表、指定格式下载、可配置下载目录。",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": [
|
||||
"bili",
|
||||
"video",
|
||||
"download"
|
||||
],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
355
third_party/homeagent-sdk/example/bili/plugin.go
vendored
355
third_party/homeagent-sdk/example/bili/plugin.go
vendored
@ -1,355 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/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
|
||||
}
|
||||
Reference in New Issue
Block a user