From c3ec23a63d5b1f5a94488af71eaddc1677776f54 Mon Sep 17 00:00:00 2001 From: mei Date: Sun, 20 Apr 2025 12:23:44 +0800 Subject: [PATCH] first commit --- README.md | 20 ++ go.mod | 3 + main.go | 565 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 588 insertions(+) create mode 100644 README.md create mode 100644 go.mod create mode 100644 main.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..25276ec --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +For lazy man + +1. Install Package + +```shell +sudo apt install playerctl +sudo apt install pulseaudio-utils +``` + +2. Build Project + +```shell +go build +``` + +3. Run Project + +```shell +./remote-volume-control -addr 0.0.0.0:8080 -user 你设置的用户名 -pass 你设置的强密码 +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fefbcdb --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/ssdomei232/linux-emote-volume-control + +go 1.23.2 diff --git a/main.go b/main.go new file mode 100644 index 0000000..0f39bd8 --- /dev/null +++ b/main.go @@ -0,0 +1,565 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "os/exec" + "regexp" + "strconv" + "strings" + "text/template" + "time" // 引入 time 包用于超时控制 +) + +// --- 配置变量 --- +var ( + listenAddr string // 监听地址和端口 + username string // Web 访问用户名 + password string // Web 访问密码 +) + +// --- HTML 模板 (已更新) --- +const indexHTML = ` + + + + + + 远程控制 + + + +
+

音量控制

+
+ + + +
+
+ +
+
加载中...
+
+ +
+

媒体控制

+
播放器信息加载中...
+
+ + +
+
+ +
+
+
+
+ + + + +` + +// --- 认证中间件 (与之前相同) --- +func basicAuth(handler http.HandlerFunc, requiredUser, requiredPassword string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if !ok || user != requiredUser || pass != requiredPassword { + w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) + // 返回 JSON 错误而不是纯文本 + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "Unauthorized"}) + log.Printf("Auth failed for user '%s' from %s", user, r.RemoteAddr) + return + } + handler(w, r) + } +} + +// --- 封装执行命令的函数 --- +// 添加了超时控制 +func executeCommand(timeout time.Duration, name string, args ...string) ([]byte, error) { + cmd := exec.Command(name, args...) + + // 设置一个通道,用于接收命令完成的信号或错误 + done := make(chan error, 1) + var output []byte + var err error + + go func() { + output, err = cmd.CombinedOutput() // 同时获取 stdout 和 stderr + done <- err + }() + + // 等待命令完成或超时 + select { + case <-time.After(timeout): + // 超时,尝试杀死进程 + if cmd.Process != nil { + if errKill := cmd.Process.Kill(); errKill != nil { + log.Printf("Failed to kill command %s after timeout: %v", name, errKill) + } + } + return nil, fmt.Errorf("command %s timed out after %v", name, timeout) + case err = <-done: + // 命令完成(可能成功也可能失败) + if err != nil { + // 如果命令执行失败,将输出也包含在错误信息中返回 + return output, fmt.Errorf("command %s failed: %v, output: %s", name, err, string(output)) + } + return output, nil + } +} + +// --- 音量处理函数 (与之前类似,但使用 executeCommand) --- +func handleVolume(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + parts := strings.Split(r.URL.Path, "/") + if len(parts) < 3 { + http.Error(w, "Bad Request: Missing action", http.StatusBadRequest) + return + } + action := parts[2] + + var pactlArgs []string + var successMsg string + + switch action { + case "up": + pactlArgs = []string{"set-sink-volume", "@DEFAULT_SINK@", "+5%"} + successMsg = "音量已增加" + case "down": + pactlArgs = []string{"set-sink-volume", "@DEFAULT_SINK@", "-5%"} + successMsg = "音量已降低" + case "toggleMute": + pactlArgs = []string{"set-sink-mute", "@DEFAULT_SINK@", "toggle"} + successMsg = "静音状态已切换" + case "set": + var payload struct { + Value int `json:"value"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "Bad Request: Invalid JSON payload", http.StatusBadRequest) + return + } + if payload.Value < 0 || payload.Value > 150 { + http.Error(w, "Bad Request: Volume must be between 0 and 150", http.StatusBadRequest) + return + } + volumeArg := fmt.Sprintf("%d%%", payload.Value) + pactlArgs = []string{"set-sink-volume", "@DEFAULT_SINK@", volumeArg} + successMsg = fmt.Sprintf("音量已设置为 %d%%", payload.Value) + default: + http.Error(w, "Bad Request: Unknown action", http.StatusBadRequest) + return + } + + _, err := executeCommand(2*time.Second, "pactl", pactlArgs...) // 2 秒超时 + if err != nil { + log.Printf("Error executing pactl command: %v", err) // 错误信息已包含 output + http.Error(w, fmt.Sprintf("Failed to execute pactl command: %v", err), http.StatusInternalServerError) + return + } + + log.Printf("Executed: pactl %s", strings.Join(pactlArgs, " ")) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": successMsg}) +} + +// --- 音量状态处理 (与之前类似,但使用 executeCommand) --- +func handleVolumeStatus(w http.ResponseWriter, r *http.Request) { + // Get Volume + outVol, errVol := executeCommand(2*time.Second, "pactl", "get-sink-volume", "@DEFAULT_SINK@") + if errVol != nil { + log.Printf("Error getting volume: %v", errVol) + http.Error(w, "Failed to get volume", http.StatusInternalServerError) + return + } + + // Get Mute Status + outMute, errMute := executeCommand(2*time.Second, "pactl", "get-sink-mute", "@DEFAULT_SINK@") + if errMute != nil { + log.Printf("Error getting mute status: %v", errMute) + http.Error(w, "Failed to get mute status", http.StatusInternalServerError) + return + } + + // Parse Volume + reVol := regexp.MustCompile(`(\d+)%`) + matchesVol := reVol.FindStringSubmatch(string(outVol)) + volume := 0 + if len(matchesVol) > 1 { + volume, _ = strconv.Atoi(matchesVol[1]) + } else { + log.Printf("Could not parse volume from output: %s", string(outVol)) + } + + // Parse Mute Status + muted := strings.Contains(strings.ToLower(string(outMute)), "yes") + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "volume": volume, + "muted": muted, + }) +} + +// --- 新增:媒体控制处理 --- +func handleMediaControl(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + parts := strings.Split(r.URL.Path, "/") + if len(parts) < 4 { // Expect /media/control/{action} + http.Error(w, "Bad Request: Missing action", http.StatusBadRequest) + return + } + action := parts[3] // Action is the 4th part + + var playerctlArgs []string + var successMsg string + + switch action { + case "play-pause": + playerctlArgs = []string{"play-pause"} + successMsg = "播放/暂停状态已切换" + case "next": + playerctlArgs = []string{"next"} + successMsg = "已切换到下一曲" + case "previous": + playerctlArgs = []string{"previous"} + successMsg = "已切换到上一曲" + case "stop": // Optional: Add stop functionality if needed + playerctlArgs = []string{"stop"} + successMsg = "已停止播放" + default: + http.Error(w, "Bad Request: Unknown media action", http.StatusBadRequest) + return + } + + output, err := executeCommand(2*time.Second, "playerctl", playerctlArgs...) // 2 秒超时 + if err != nil { + errMsg := fmt.Sprintf("Failed to execute playerctl command: %v", err) + log.Print(errMsg) // 修复点:改为 log.Print + // 检查是否是 "No players found" 错误 + if strings.Contains(string(output), "No players found") || strings.Contains(err.Error(), "No players found") { + http.Error(w, `{"error": "没有找到活动的播放器"}`, http.StatusNotFound) // 返回 404 + } else { + http.Error(w, fmt.Sprintf(`{"error": "%s"}`, errMsg), http.StatusInternalServerError) + } + return + } + + log.Printf("Executed: playerctl %s", strings.Join(playerctlArgs, " ")) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": successMsg}) +} + +// --- 新增:媒体状态处理 --- +func handleMediaStatus(w http.ResponseWriter, r *http.Request) { + // 使用 playerctl metadata --format 获取 JSON 输出,简化解析 + format := `{"playerName":"{{playerName}}", "title":"{{markup_escape(title)}}", "artist":"{{markup_escape(artist)}}", "album":"{{markup_escape(album)}}", "status":"{{status}}"}` + args := []string{"metadata", "--format", format} + + output, err := executeCommand(3*time.Second, "playerctl", args...) // 3 秒超时 + + // 检查 playerctl 是否因为没有播放器而退出 + // playerctl 在找不到播放器时通常会返回非零退出码,并可能在 stderr 输出 "No players found" 或类似信息 + if err != nil { + errStr := err.Error() + if strings.Contains(errStr, "No players found") || strings.Contains(errStr, "No player could handle this command") || strings.Contains(string(output), "No players found") { + // 这是正常情况,表示没有播放器在运行 + log.Println("No active media player found.") + w.Header().Set("Content-Type", "application/json") + // 返回特定的 JSON 表示没有播放器,或者返回 404 也可以 + // http.Error(w, `{"error": "没有找到活动的播放器"}`, http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"status": "Stopped", "title": "", "error": "没有找到活动的播放器"}) + return + } + // 其他错误 + log.Printf("Error getting media status: %v", err) // 错误已包含输出 + http.Error(w, fmt.Sprintf(`{"error": "获取媒体状态失败: %v"}`, err), http.StatusInternalServerError) + return + } + + // playerctl 成功执行,输出应该是 JSON 格式 + w.Header().Set("Content-Type", "application/json") + // 直接将 playerctl 的 JSON 输出写入响应 + _, writeErr := w.Write(output) + if writeErr != nil { + log.Printf("Error writing media status response: %v", writeErr) + } +} + +func handleRoot(w http.ResponseWriter, r *http.Request) { + // 使用基本认证中间件保护根路径 + basicAuth(func(w http.ResponseWriter, r *http.Request) { + // 定义模板数据 + templateData := struct { + Username string + Password string + }{ + Username: username, + Password: password, + } + + // 渲染 HTML 模板并注入认证信息 + tmpl, err := template.New("index").Parse(indexHTML) + if err != nil { + log.Printf("Error parsing HTML template: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // 执行模板并写入响应 + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.Execute(w, templateData); err != nil { + log.Printf("Error executing HTML template: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } + }, username, password)(w, r) +} + +// --- 主函数 (已更新路由) --- +func main() { + flag.StringVar(&listenAddr, "addr", ":8080", "监听的地址和端口") + flag.StringVar(&username, "user", "admin", "Web 访问的用户名") + flag.StringVar(&password, "pass", "password", "Web 访问的密码 (强烈建议修改!)") + flag.Parse() + + if password == "password" { + log.Println("警告: 正在使用默认密码 'password'。请使用 -pass 参数设置一个强密码。") + } + + // 注册 HTTP 路由处理函数 + http.HandleFunc("/", handleRoot) // Auth is applied inside handleRoot + + // 音量相关路由 (添加 /status 子路径) + http.HandleFunc("/volume/status", basicAuth(handleVolumeStatus, username, password)) + http.HandleFunc("/volume/", basicAuth(handleVolume, username, password)) // 这个要放在后面,避免匹配 /volume/status + + // 媒体相关路由 + http.HandleFunc("/media/status", basicAuth(handleMediaStatus, username, password)) + http.HandleFunc("/media/control/", basicAuth(handleMediaControl, username, password)) // 匹配 /media/control/{action} + + log.Printf("服务器正在启动,监听地址: %s", listenAddr) + log.Printf("请使用用户名: %s 访问", username) + log.Printf("请使用通过 -pass 参数提供的密码 (或默认密码 'password')") + log.Fatal(http.ListenAndServe(listenAddr, nil)) +}