Files
wehub-resource-sync ead81af521
CI / go (push) Waiting to run
CI / build (darwin, macos-14) (push) Waiting to run
CI / build (windows, windows-2025) (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Failing after 0s
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

265 lines
7.6 KiB
Go

package player
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"github.com/bjarneo/cliamp/internal/sshurl"
"github.com/gopxl/beep/v2"
"github.com/gopxl/beep/v2/flac"
"github.com/gopxl/beep/v2/mp3"
"github.com/gopxl/beep/v2/vorbis"
"github.com/gopxl/beep/v2/wav"
"github.com/bjarneo/cliamp/internal/httpclient"
)
// SupportedExts is the set of file extensions the player can decode.
var SupportedExts = map[string]bool{
".mp3": true,
".wav": true,
".flac": true,
".ogg": true,
".m4a": true,
".aac": true,
".aacp": true,
".m4b": true,
".alac": true,
".wma": true,
".opus": true,
".webm": true,
}
// httpClient is the shared streaming HTTP client. See internal/httpclient
// for configuration rationale (no overall timeout, HTTP/2 disabled for Icecast).
var httpClient = httpclient.Streaming
// isURL reports whether path is an HTTP or HTTPS URL.
func isURL(path string) bool {
return strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://")
}
// isSSH reports whether path is an SSH remote path (ssh://host/path).
func isSSH(path string) bool {
return strings.HasPrefix(path, "ssh://")
}
// sshReadCloser wraps an SSH subprocess stdout pipe as an io.ReadCloser.
// Closing it kills the SSH process and reaps the child.
type sshReadCloser struct {
pipe io.ReadCloser // cmd.StdoutPipe()
cmd *exec.Cmd
}
func (s *sshReadCloser) Read(p []byte) (int, error) {
return s.pipe.Read(p)
}
func (s *sshReadCloser) Close() error {
// Kill the SSH process if still running.
if s.cmd.Process != nil {
_ = s.cmd.Process.Kill()
}
_ = s.pipe.Close()
waitErr := s.cmd.Wait() // reap zombie
// Process.Kill causes Wait to return "signal: killed" — that's expected.
if waitErr != nil {
if exitErr, ok := waitErr.(*exec.ExitError); ok && exitErr.ExitCode() != -1 {
return fmt.Errorf("ssh: %w", waitErr)
}
}
return nil
}
// shellQuoteSSH wraps a string in single quotes for safe use in a remote shell command.
// Single quotes inside the string are escaped as '\” (end quote, escaped quote, start quote).
func shellQuoteSSH(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}
// openSSHSource opens a remote file via SSH by running "ssh host cat remotePath"
// and returning the stdout pipe as an io.ReadCloser.
// Path format: ssh://hostname/absolute/path/to/file
func openSSHSource(path string) (sourceResult, error) {
parsed, err := sshurl.Parse(path)
if err != nil {
return sourceResult{}, err
}
catCmd := "cat -- " + shellQuoteSSH(parsed.Path)
args := parsed.SSHArgs()
args = append(args, catCmd)
cmd := exec.Command("ssh", args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return sourceResult{}, fmt.Errorf("ssh stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return sourceResult{}, fmt.Errorf("ssh start: %w", err)
}
rc := &sshReadCloser{pipe: stdout, cmd: cmd}
return sourceResult{body: rc, contentLength: -1}, nil
}
// matchCustomURI returns the StreamerFactory for the given path if it matches
// a registered custom URI scheme prefix, or nil if no scheme matches.
func (p *Player) matchCustomURI(path string) StreamerFactory {
for scheme, factory := range p.customFactories {
if strings.HasPrefix(path, scheme) {
return factory
}
}
return nil
}
// sourceResult holds the opened stream and optional HTTP metadata.
type sourceResult struct {
body io.ReadCloser
contentType string // e.g. "audio/aacp"; empty for local files
contentLength int64 // -1 if unknown; from Content-Length header for HTTP
}
// openSourceAt opens a ReadCloser for the given path, handling both
// local files and HTTP URLs.
// offset using an HTTP Range request (Range: bytes=offset-). For local files
// the offset is ignored (use decoder.Seek for local files).
func openSourceAt(path string, byteOffset int64, onMeta func(string)) (sourceResult, error) {
if isSSH(path) {
return openSSHSource(path)
}
if !isURL(path) {
f, err := os.Open(path)
return sourceResult{body: f, contentLength: -1}, err
}
req, err := http.NewRequest("GET", path, nil)
if err != nil {
return sourceResult{}, fmt.Errorf("http request: %w", err)
}
req.Header.Set("User-Agent", "cliamp/1.0 (https://github.com/bjarneo/cliamp)")
// Request ICY metadata — servers that don't support it simply ignore this header.
req.Header.Set("Icy-MetaData", "1")
if byteOffset > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", byteOffset))
}
resp, err := httpClient.Do(req)
if err != nil {
return sourceResult{}, fmt.Errorf("http get: %w", err)
}
// Accept 200 OK (full response) or 206 Partial Content (range response).
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
resp.Body.Close()
return sourceResult{}, fmt.Errorf("http status %s", resp.Status)
}
body := resp.Body
// Wrap in ICY reader if the server provides a metaint interval.
if metaStr := resp.Header.Get("Icy-Metaint"); metaStr != "" && onMeta != nil {
if metaInt, err := strconv.Atoi(metaStr); err == nil && metaInt > 0 {
body = newIcyReader(body, metaInt, onMeta)
}
}
return sourceResult{
body: body,
contentType: resp.Header.Get("Content-Type"),
contentLength: resp.ContentLength,
}, nil
}
// extFromContentType maps an HTTP Content-Type to a file extension.
// Returns "" if the type is unrecognized.
func extFromContentType(ct string) string {
// Strip parameters (e.g. "audio/aacp; charset=utf-8" → "audio/aacp").
if i := strings.IndexByte(ct, ';'); i >= 0 {
ct = ct[:i]
}
ct = strings.TrimSpace(strings.ToLower(ct))
switch ct {
case "audio/aac", "audio/aacp", "audio/x-aac":
return ".aac"
case "audio/mpeg", "audio/mp3":
return ".mp3"
case "audio/ogg", "application/ogg":
return ".ogg"
case "audio/flac":
return ".flac"
case "audio/wav", "audio/x-wav":
return ".wav"
case "audio/mp4", "audio/x-m4a":
return ".m4a"
case "audio/opus":
return ".opus"
}
return ""
}
// formatExt returns the audio format extension for a path.
// For URLs, it parses the path component (ignoring query params),
// checks a "format" query param as fallback, and defaults to ".mp3".
func formatExt(path string) string {
if !isURL(path) {
return strings.ToLower(filepath.Ext(path))
}
u, err := url.Parse(path)
if err != nil {
return ".mp3"
}
ext := strings.ToLower(filepath.Ext(u.Path))
if ext == "" || ext == ".view" {
if f := u.Query().Get("format"); f != "" {
return "." + strings.ToLower(f)
}
return ".mp3"
}
return ext
}
// needsFFmpeg reports whether the given extension requires ffmpeg to decode.
func needsFFmpeg(ext string) bool {
switch ext {
case ".m4a", ".aac", ".aacp", ".m4b", ".alac", ".wma", ".opus", ".webm":
return true
}
return false
}
// isHLS reports whether the extension denotes an HLS playlist that ffmpeg must
// open by URL (so it can fetch and demux the segments itself).
func isHLS(ext string) bool { return ext == ".m3u8" }
// isBufferedURL reports whether the given URL requires the buffered download
// + ffmpeg pipeline. Returns true if a registered matcher matches the URL.
func (p *Player) isBufferedURL(path string) bool {
if p.bufferedURLMatch == nil {
return false
}
return p.bufferedURLMatch(path)
}
// decodeWithExt selects the decoder using an explicit extension.
func decodeWithExt(rc io.ReadCloser, ext, path string, sr beep.SampleRate, bitDepth int) (beep.StreamSeekCloser, beep.Format, error) {
if needsFFmpeg(ext) {
return decodeFFmpeg(path, sr, bitDepth)
}
switch ext {
case ".wav":
return wav.Decode(rc)
case ".flac":
return flac.Decode(rc)
case ".ogg":
return vorbis.Decode(rc)
default:
return mp3.Decode(rc)
}
}