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))
}