|
| 1 | +// Package splayer 控制 SPlayer-Next 播放器:通过其外部 HTTP API 实现播放控制、 |
| 2 | +// 播放状态/歌词查询与会话播放列表管理。 |
| 3 | +// 需在 SPlayer-Next「设置 → 外部 API」中开启并勾选「允许局域网访问」。 |
| 4 | +// API 文档: https://github.com/SPlayer-Dev/SPlayer-Next/blob/dev/docs/api.md |
| 5 | +package splayer |
| 6 | + |
| 7 | +import ( |
| 8 | + "bytes" |
| 9 | + "encoding/json" |
| 10 | + "fmt" |
| 11 | + "io" |
| 12 | + "net/http" |
| 13 | + "os" |
| 14 | + "time" |
| 15 | + |
| 16 | + "github.com/pkg/errors" |
| 17 | +) |
| 18 | + |
| 19 | +// apiBase SPlayer-Next 外部 API 地址,可用环境变量 SPLAYER_API 覆盖 |
| 20 | +var apiBase = "http://192.168.1.242:14558" |
| 21 | + |
| 22 | +var httpClient = &http.Client{Timeout: 5 * time.Second} |
| 23 | + |
| 24 | +func init() { |
| 25 | + if v := os.Getenv("SPLAYER_API"); v != "" { |
| 26 | + apiBase = v |
| 27 | + } |
| 28 | +} |
| 29 | + |
| 30 | +// artist 歌手 |
| 31 | +type artist struct { |
| 32 | + Name string `json:"name"` |
| 33 | +} |
| 34 | + |
| 35 | +// album 专辑 |
| 36 | +type album struct { |
| 37 | + Name string `json:"name"` |
| 38 | +} |
| 39 | + |
| 40 | +// track 曲目信息(外部 API Track 的子集) |
| 41 | +type track struct { |
| 42 | + ID string `json:"id"` |
| 43 | + Source string `json:"source"` |
| 44 | + Title string `json:"title"` |
| 45 | + Artists []artist `json:"artists"` |
| 46 | + Album album `json:"album"` |
| 47 | + Duration int64 `json:"duration"` // 毫秒 |
| 48 | + Cover string `json:"cover"` |
| 49 | + Codec string `json:"-"` |
| 50 | + BitRate int64 `json:"-"` |
| 51 | +} |
| 52 | + |
| 53 | +// UnmarshalJSON 从原始 Track 提取常用字段(quality 嵌套在 track 内) |
| 54 | +func (t *track) UnmarshalJSON(data []byte) error { |
| 55 | + type alias track |
| 56 | + var a struct { |
| 57 | + alias |
| 58 | + Quality *struct { |
| 59 | + Codec string `json:"codec"` |
| 60 | + BitRate int64 `json:"bitRate"` |
| 61 | + } `json:"quality"` |
| 62 | + } |
| 63 | + if err := json.Unmarshal(data, &a); err != nil { |
| 64 | + return err |
| 65 | + } |
| 66 | + *t = track(a.alias) |
| 67 | + if a.Quality != nil { |
| 68 | + t.Codec = a.Quality.Codec |
| 69 | + t.BitRate = a.Quality.BitRate |
| 70 | + } |
| 71 | + return nil |
| 72 | +} |
| 73 | + |
| 74 | +// nowPlaying GET /api/now-playing 响应 |
| 75 | +type nowPlaying struct { |
| 76 | + Track *track `json:"track"` |
| 77 | + Position int64 `json:"position"` // 毫秒 |
| 78 | + Playing bool `json:"playing"` |
| 79 | + State string `json:"state"` |
| 80 | + LyricAvailable bool `json:"lyricAvailable"` |
| 81 | + LyricLineCount int `json:"lyricLineCount"` |
| 82 | +} |
| 83 | + |
| 84 | +// lyricWord 逐字歌词单词(词级时间戳部分歌词源才有,缺失时为 0, |
| 85 | +// 渲染端自动回退行内匀速近似) |
| 86 | +type lyricWord struct { |
| 87 | + Word string `json:"word"` |
| 88 | + StartTime int64 `json:"startTime"` |
| 89 | + EndTime int64 `json:"endTime"` |
| 90 | +} |
| 91 | + |
| 92 | +// lyricLine 歌词行(时间为毫秒) |
| 93 | +type lyricLine struct { |
| 94 | + Words []lyricWord `json:"words"` |
| 95 | + TranslatedLyric string `json:"translatedLyric"` |
| 96 | + StartTime int64 `json:"startTime"` |
| 97 | + EndTime int64 `json:"endTime"` |
| 98 | +} |
| 99 | + |
| 100 | +// lyricData GET /api/lyrics 响应 |
| 101 | +type lyricData struct { |
| 102 | + TrackID string `json:"trackId"` |
| 103 | + Lyric []lyricLine `json:"lyric"` |
| 104 | + LyricOffsetMs int64 `json:"lyricOffsetMs"` |
| 105 | +} |
| 106 | + |
| 107 | +// playerStatus GET /api/status 响应 |
| 108 | +type playerStatus struct { |
| 109 | + State string `json:"state"` |
| 110 | + Position int64 `json:"position"` |
| 111 | + Duration int64 `json:"duration"` |
| 112 | + Volume float64 `json:"volume"` |
| 113 | + IsFinished bool `json:"isFinished"` |
| 114 | +} |
| 115 | + |
| 116 | +// apiGet GET /api/<path> 并解析 JSON 响应 |
| 117 | +func apiGet(path string, out any) error { |
| 118 | + resp, err := httpClient.Get(apiBase + "/api/" + path) |
| 119 | + if err != nil { |
| 120 | + return errors.Wrapf(err, "无法连接 SPlayer-Next(%s)", apiBase) |
| 121 | + } |
| 122 | + defer resp.Body.Close() |
| 123 | + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) |
| 124 | + if resp.StatusCode != http.StatusOK { |
| 125 | + return errors.Errorf("SPlayer API %s 返回 %d: %s", path, resp.StatusCode, truncateBytes(body, 120)) |
| 126 | + } |
| 127 | + if err := json.Unmarshal(body, out); err != nil { |
| 128 | + return errors.Wrapf(err, "解析 SPlayer API %s 响应失败", path) |
| 129 | + } |
| 130 | + return nil |
| 131 | +} |
| 132 | + |
| 133 | +// apiPost POST /api/<path>,body 可为 nil(控制类接口无请求体) |
| 134 | +func apiPost(path string, body any) error { |
| 135 | + var rd io.Reader |
| 136 | + if body != nil { |
| 137 | + b, err := json.Marshal(body) |
| 138 | + if err != nil { |
| 139 | + return err |
| 140 | + } |
| 141 | + rd = bytes.NewReader(b) |
| 142 | + } |
| 143 | + resp, err := httpClient.Post(apiBase+"/api/"+path, "application/json", rd) |
| 144 | + if err != nil { |
| 145 | + return errors.Wrapf(err, "无法连接 SPlayer-Next(%s)", apiBase) |
| 146 | + } |
| 147 | + defer resp.Body.Close() |
| 148 | + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) |
| 149 | + if resp.StatusCode != http.StatusOK { |
| 150 | + return errors.Errorf("SPlayer API %s 返回 %d: %s", path, resp.StatusCode, truncateBytes(respBody, 120)) |
| 151 | + } |
| 152 | + return nil |
| 153 | +} |
| 154 | + |
| 155 | +// getNowPlaying 获取当前播放快照(曲目+进度) |
| 156 | +func getNowPlaying() (*nowPlaying, error) { |
| 157 | + np := &nowPlaying{} |
| 158 | + if err := apiGet("now-playing", np); err != nil { |
| 159 | + return nil, err |
| 160 | + } |
| 161 | + return np, nil |
| 162 | +} |
| 163 | + |
| 164 | +// getLyric 获取当前曲目完整歌词 |
| 165 | +func getLyric() (*lyricData, error) { |
| 166 | + ld := &lyricData{} |
| 167 | + if err := apiGet("lyrics", ld); err != nil { |
| 168 | + return nil, err |
| 169 | + } |
| 170 | + return ld, nil |
| 171 | +} |
| 172 | + |
| 173 | +// getStatus 获取播放状态 |
| 174 | +func getStatus() (*playerStatus, error) { |
| 175 | + st := &playerStatus{} |
| 176 | + if err := apiGet("status", st); err != nil { |
| 177 | + return nil, err |
| 178 | + } |
| 179 | + return st, nil |
| 180 | +} |
| 181 | + |
| 182 | +// playerControl 执行无参控制命令(play/pause/stop/next/prev) |
| 183 | +func playerControl(name string) error { |
| 184 | + return apiPost(name, nil) |
| 185 | +} |
| 186 | + |
| 187 | +// seekTo 跳转到指定毫秒位置 |
| 188 | +func seekTo(positionMs int64) error { |
| 189 | + return apiPost("seek", map[string]int64{"positionMs": positionMs}) |
| 190 | +} |
| 191 | + |
| 192 | +// setVolume 设置音量(0~1) |
| 193 | +func setVolume(volume float64) error { |
| 194 | + return apiPost("volume", map[string]float64{"volume": volume}) |
| 195 | +} |
| 196 | + |
| 197 | +// truncateBytes 截断字节串用于错误信息 |
| 198 | +func truncateBytes(b []byte, n int) string { |
| 199 | + if len(b) > n { |
| 200 | + return string(b[:n]) + "..." |
| 201 | + } |
| 202 | + return string(b) |
| 203 | +} |
| 204 | + |
| 205 | +// lineText 歌词行文本:主歌词 + 可选翻译 |
| 206 | +func (l *lyricLine) lineText() string { |
| 207 | + text := "" |
| 208 | + for _, w := range l.Words { |
| 209 | + text += w.Word |
| 210 | + } |
| 211 | + if l.TranslatedLyric != "" { |
| 212 | + text += "\n" + l.TranslatedLyric |
| 213 | + } |
| 214 | + return text |
| 215 | +} |
| 216 | + |
| 217 | +// currentLine 定位播放位置对应的歌词行下标(-1 表示前奏/间奏无歌词) |
| 218 | +func (ld *lyricData) currentLine(positionMs int64) int { |
| 219 | + idx := -1 |
| 220 | + for i := range ld.Lyric { |
| 221 | + if ld.Lyric[i].StartTime <= positionMs { |
| 222 | + idx = i |
| 223 | + } else { |
| 224 | + break |
| 225 | + } |
| 226 | + } |
| 227 | + return idx |
| 228 | +} |
| 229 | + |
| 230 | +// fmtMs 毫秒 → mm:ss |
| 231 | +func fmtMs(ms int64) string { |
| 232 | + if ms < 0 { |
| 233 | + ms = 0 |
| 234 | + } |
| 235 | + s := ms / 1000 |
| 236 | + return fmt.Sprintf("%02d:%02d", s/60, s%60) |
| 237 | +} |
0 commit comments