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

682 lines
20 KiB
Go

// Package resolve converts CLI arguments (files, directories, globs, URLs,
// M3U playlists, and RSS feeds) into a flat list of playlist tracks.
package resolve
import (
"bufio"
"bytes"
"context"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"io/fs"
"mime"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/bjarneo/cliamp/player"
"github.com/bjarneo/cliamp/playlist"
"github.com/kkdai/youtube/v2"
)
// ytdlCookiesFromVal stores the browser name passed to yt-dlp's
// --cookies-from-browser flag. atomic.Value allows lock-free reads on the
// resolve hot path while permitting late binding from main.go.
var ytdlCookiesFromVal atomic.Value
// SetYTDLCookiesFrom configures yt-dlp to use cookies from the given browser
// (e.g. "firefox", "chrome", "brave") for any --flat-playlist resolution.
// Pass an empty string to disable.
func SetYTDLCookiesFrom(browser string) {
ytdlCookiesFromVal.Store(browser)
}
func ytdlCookiesFrom() string {
v, _ := ytdlCookiesFromVal.Load().(string)
return v
}
// httpClient is used for feed and M3U resolution. It has a generous but
// finite timeout to prevent hanging on unresponsive servers.
var httpClient = &http.Client{
Timeout: 30 * time.Second,
Transport: &uaTransport{rt: http.DefaultTransport},
}
// sniffClient probes content types during Args classification, which runs on
// the startup path before the TUI launches. It uses a short timeout so a slow
// or unresponsive server can stall startup by at most a few seconds rather
// than the 30s the feed/M3U client allows.
var sniffClient = &http.Client{
Timeout: 5 * time.Second,
Transport: &uaTransport{rt: http.DefaultTransport},
}
// uaTransport injects the cliamp User-Agent header into every request.
type uaTransport struct{ rt http.RoundTripper }
func (t *uaTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.Header.Set("User-Agent", "cliamp/1.0 (https://github.com/bjarneo/cliamp)")
return t.rt.RoundTrip(req)
}
// Result holds the output of Args: instantly-resolved tracks and
// remote URLs (feeds, M3U) that need async HTTP fetching.
type Result struct {
Tracks []playlist.Track // local files, dirs, plain stream URLs
Pending []string // feed/M3U URLs to resolve asynchronously
}
// Args separates CLI arguments into immediately-resolved local tracks
// and pending remote URLs (feeds, M3U) that require HTTP fetching.
func Args(args []string) (Result, error) {
var r Result
var files []string
for _, arg := range args {
if playlist.IsURL(arg) {
if playlist.IsFeed(arg) || playlist.IsM3U(arg) || playlist.IsPLS(arg) || playlist.IsYouTubeURL(arg) || playlist.IsYTDL(arg) || playlist.IsXiaoyuzhouEpisode(arg) || sniffFeedURL(arg) {
r.Pending = append(r.Pending, arg)
} else {
files = append(files, arg)
}
continue
}
matches, err := filepath.Glob(arg)
if err != nil || len(matches) == 0 {
matches = []string{arg}
}
for _, path := range matches {
if playlist.IsLocalM3U(path) {
tracks, err := resolveLocalM3U(path)
if err != nil {
return r, fmt.Errorf("loading m3u %s: %w", path, err)
}
r.Tracks = append(r.Tracks, tracks...)
continue
}
if playlist.IsLocalPLS(path) {
tracks, err := resolveLocalPLS(path)
if err != nil {
return r, fmt.Errorf("loading pls %s: %w", path, err)
}
r.Tracks = append(r.Tracks, tracks...)
continue
}
resolved, err := CollectAudioFiles(path)
if err != nil {
return r, fmt.Errorf("scanning %s: %w", path, err)
}
files = append(files, resolved...)
}
}
r.Tracks = append(r.Tracks, scanTracks(files)...)
return r, nil
}
// Remote fetches feed and M3U URLs and returns the resolved tracks.
func Remote(urls []string) ([]playlist.Track, error) {
var tracks []playlist.Track
for _, u := range urls {
switch {
case playlist.IsXiaoyuzhouEpisode(u):
t, err := resolveXiaoyuzhouEpisode(u)
if err != nil {
return nil, fmt.Errorf("resolving xiaoyuzhou episode %s: %w", u, err)
}
tracks = append(tracks, t...)
case playlist.IsYouTubeMusicURL(u):
// YouTube Music requires yt-dlp; the native YouTube API client
// does not support music.youtube.com playlists.
t, err := resolveYTDL(u)
if err != nil {
return nil, fmt.Errorf("resolving youtube music %s: %w", u, err)
}
tracks = append(tracks, t...)
case playlist.IsYouTubeURL(u):
t, err := resolveYouTube(u)
if err != nil {
return nil, fmt.Errorf("resolving youtube %s: %w", u, err)
}
tracks = append(tracks, t...)
case playlist.IsYTDL(u):
t, err := resolveYTDL(u)
if err != nil {
return nil, fmt.Errorf("resolving yt-dlp %s: %w", u, err)
}
tracks = append(tracks, t...)
case playlist.IsFeed(u):
t, err := resolveFeed(u)
if err != nil {
return nil, fmt.Errorf("resolving feed %s: %w", u, err)
}
tracks = append(tracks, t...)
case playlist.IsM3U(u):
t, err := resolveM3U(u)
if err != nil {
return nil, fmt.Errorf("resolving m3u %s: %w", u, err)
}
tracks = append(tracks, t...)
case playlist.IsPLS(u):
t, err := resolvePLS(u)
if err != nil {
return nil, fmt.Errorf("resolving pls %s: %w", u, err)
}
tracks = append(tracks, t...)
default:
// URL was classified as a feed by content-type sniffing.
t, err := resolveFeed(u)
if err != nil {
return nil, fmt.Errorf("resolving feed %s: %w", u, err)
}
tracks = append(tracks, t...)
}
}
return tracks, nil
}
// sniffFeedURL does a HEAD request and returns true if the Content-Type
// indicates an RSS/Atom feed. Used as a fallback when the URL has no
// recognizable file extension (e.g. https://feeds.megaphone.fm/GLT1412515089).
func sniffFeedURL(rawURL string) bool {
// URLs with a known audio extension are never feeds — skip the
// network round-trip to avoid misclassification when CDNs return
// unexpected Content-Types for HEAD requests.
if u, err := url.Parse(rawURL); err == nil {
if player.SupportedExts[strings.ToLower(filepath.Ext(u.Path))] {
return false
}
}
resp, err := sniffClient.Head(rawURL)
if err != nil {
return false
}
resp.Body.Close()
ct := resp.Header.Get("Content-Type")
mediaType, _, _ := mime.ParseMediaType(ct)
switch mediaType {
case "application/rss+xml", "application/atom+xml",
"application/xml", "text/xml":
return true
}
return false
}
// CollectAudioFiles returns audio file paths for the given argument.
// If path is a directory, it walks it recursively collecting supported files.
// If path is a file with a supported extension, it returns it directly.
func CollectAudioFiles(path string) ([]string, error) {
info, err := os.Stat(path)
if err != nil {
return nil, err
}
if !info.IsDir() {
if player.SupportedExts[strings.ToLower(filepath.Ext(path))] {
return []string{path}, nil
}
return nil, nil
}
var files []string
err = filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && player.SupportedExts[strings.ToLower(filepath.Ext(p))] {
files = append(files, p)
}
return nil
})
if err != nil {
return nil, err
}
slices.Sort(files)
return files, nil
}
// LocalPlaylist resolves a local M3U/M3U8 or PLS playlist file into tracks.
// Relative paths are resolved by the underlying parser against the playlist
// file's directory, matching normal playback import behavior.
func LocalPlaylist(path string) ([]playlist.Track, error) {
if playlist.IsLocalM3U(path) {
return resolveLocalM3U(path)
}
if playlist.IsLocalPLS(path) {
return resolveLocalPLS(path)
}
return nil, fmt.Errorf("unsupported playlist file %q", path)
}
// scanTracks converts file paths to Tracks concurrently, preserving order.
func scanTracks(files []string) []playlist.Track {
if len(files) == 0 {
return nil
}
tracks := make([]playlist.Track, len(files))
workers := min(len(files), 8)
var wg sync.WaitGroup
ch := make(chan int, len(files))
for i := range files {
ch <- i
}
close(ch)
wg.Add(workers)
for range workers {
go func() {
defer wg.Done()
for i := range ch {
tracks[i] = playlist.TrackFromPath(files[i])
}
}()
}
wg.Wait()
return tracks
}
// resolveFeed fetches a podcast RSS feed and returns tracks with metadata.
func resolveFeed(feedURL string) ([]playlist.Track, error) {
resp, err := httpClient.Get(feedURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("http status %s", resp.Status)
}
var rss struct {
Channel struct {
Title string `xml:"title"`
Items []struct {
Title string `xml:"title"`
Duration string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd duration"`
Enclosure struct {
URL string `xml:"url,attr"`
Type string `xml:"type,attr"`
} `xml:"enclosure"`
} `xml:"item"`
} `xml:"channel"`
}
// Bound the read so a huge or malicious feed can't exhaust memory.
const maxFeedBody = 32 << 20 // 32 MB
if err := xml.NewDecoder(io.LimitReader(resp.Body, maxFeedBody)).Decode(&rss); err != nil {
return nil, fmt.Errorf("parsing feed: %w", err)
}
var tracks []playlist.Track
for _, item := range rss.Channel.Items {
if item.Enclosure.URL == "" {
continue
}
tracks = append(tracks, playlist.Track{
Path: item.Enclosure.URL,
Title: item.Title,
Artist: rss.Channel.Title,
Stream: true,
DurationSecs: parseItunesDuration(item.Duration),
})
}
return tracks, nil
}
// maxM3UBody caps how much of a remote playlist we read before classifying it.
// HLS/M3U playlists are tiny (a few KB); 1 MB is a generous safety bound.
const maxM3UBody = 1 << 20
// resolveM3U fetches an M3U/M3U8 URL. HLS playlists (master or media) are a
// single live/VOD stream — not a track list — so the original URL is handed to
// the player, where ffmpeg resolves the relative chunklist/segment URIs and
// follows the live segment window. Plain M3U files are parsed as track lists.
func resolveM3U(m3uURL string) ([]playlist.Track, error) {
resp, err := httpClient.Get(m3uURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("http status %s", resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxM3UBody))
if err != nil {
return nil, err
}
if isHLSPlaylist(body) {
// Parsing an HLS playlist as a track list would extract the relative
// "chunklist_*.m3u8" URI as a bogus local-file track and fail with
// "open source: ... no such file or directory".
t := playlist.TrackFromPath(m3uURL) // Stream=true; title derived from URL
// #EXT-X-ENDLIST only appears in media playlists, so VOD behind a
// master playlist is conservatively treated as live.
t.Realtime = !bytes.Contains(body, []byte("#EXT-X-ENDLIST"))
return []playlist.Track{t}, nil
}
entries, err := parseM3U(bytes.NewReader(body), "")
if err != nil {
return nil, err
}
return entriesToTracks(entries), nil
}
// resolvePLS fetches a PLS playlist URL and returns tracks.
func resolvePLS(plsURL string) ([]playlist.Track, error) {
resp, err := httpClient.Get(plsURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("http status %s", resp.Status)
}
entries, err := parsePLS(resp.Body)
if err != nil {
return nil, err
}
return plsEntriesToTracks(entries), nil
}
// isHLSPlaylist reports whether an M3U body is an HLS playlist (master or media)
// rather than a plain list of media URLs. HLS is identified by its #EXT-X-* tags.
func isHLSPlaylist(body []byte) bool {
return bytes.Contains(body, []byte("#EXT-X-STREAM-INF")) || // master
bytes.Contains(body, []byte("#EXT-X-TARGETDURATION")) || // media
bytes.Contains(body, []byte("#EXT-X-MEDIA-SEQUENCE")) // media
}
// ytdlFlatEntry holds JSON fields from yt-dlp --flat-playlist output.
type ytdlFlatEntry struct {
URL string `json:"url"`
WebpageURL string `json:"webpage_url"`
Title string `json:"title"`
Uploader string `json:"uploader"`
PlaylistUploader string `json:"playlist_uploader"`
WebpageURLBasename string `json:"webpage_url_basename"`
Duration float64 `json:"duration"`
}
// ytdlFullEntry holds JSON fields from yt-dlp --print-json output (download mode).
type ytdlFullEntry struct {
Title string `json:"title"`
Uploader string `json:"uploader"`
Filename string `json:"_filename"`
}
// YTDLRadioInitialItems is the number of tracks fetched in the first pass
// for YouTube Radio/Mix playlists. The UI uses this as the batch offset
// when starting incremental loading.
const YTDLRadioInitialItems = 20
// resolveYouTube uses the kkdai/youtube library to resolve YouTube URLs.
// For playlist URLs it enumerates all entries natively; for single video URLs
// it returns a single track with metadata from the YouTube API.
func resolveYouTube(pageURL string) ([]playlist.Track, error) {
client := youtube.Client{}
// Only attempt playlist resolution if the URL contains a "list=" parameter,
// avoiding a wasted API call for single-video URLs (the common case).
u, _ := url.Parse(pageURL)
isList := u != nil && u.Query().Get("list") != ""
// YouTube Radio/Mix playlists (list=RD...) are dynamically generated and
// unsupported by the native library. Route them directly to yt-dlp.
listID := ""
if u != nil {
listID = u.Query().Get("list")
}
if isList && strings.HasPrefix(listID, "RD") {
if tracks, err := resolveYTDL(pageURL, YTDLRadioInitialItems); err == nil && len(tracks) > 0 {
return tracks, nil
}
}
if isList {
pl, err := client.GetPlaylist(pageURL)
if err == nil && len(pl.Videos) > 0 {
tracks := make([]playlist.Track, 0, len(pl.Videos))
for _, entry := range pl.Videos {
tracks = append(tracks, playlist.Track{
Path: "https://www.youtube.com/watch?v=" + entry.ID,
Title: entry.Title,
Artist: entry.Author,
Stream: true,
DurationSecs: int(entry.Duration.Seconds()),
})
}
return tracks, nil
}
// Native library failed (e.g. YouTube Radio/Mix playlists are dynamic
// and unsupported). Fall back to yt-dlp which handles them.
if tracks, err := resolveYTDL(pageURL); err == nil && len(tracks) > 0 {
return tracks, nil
}
}
// Single video.
video, err := client.GetVideo(pageURL)
if err != nil {
return nil, fmt.Errorf("youtube resolve: %w", err)
}
return []playlist.Track{{
Path: "https://www.youtube.com/watch?v=" + video.ID,
Title: video.Title,
Artist: video.Author,
Stream: true,
DurationSecs: int(video.Duration.Seconds()),
}}, nil
}
// ResolveYTDLBatch is like resolveYTDL but fetches a specific range
// [start, start+count) from the playlist. Exported for UI incremental loading.
// ResolveYTDLBatch fetches tracks starting at offset `start`.
// If count > 0, fetches at most `count` items; if count == 0, fetches all remaining.
func ResolveYTDLBatch(pageURL string, start, count int) ([]playlist.Track, error) {
end := 0
if count > 0 {
end = start + count
}
return resolveYTDLRange(pageURL, start, end)
}
// resolveYTDL uses yt-dlp --flat-playlist to quickly enumerate tracks.
// Tracks are returned with their page URLs as Path (not direct audio URLs).
// If maxItems > 0, only the first maxItems tracks are fetched.
func resolveYTDL(pageURL string, maxItems ...int) ([]playlist.Track, error) {
end := 0
if len(maxItems) > 0 {
end = maxItems[0]
}
return resolveYTDLRange(pageURL, 0, end)
}
func resolveYTDLRange(pageURL string, start, end int) ([]playlist.Track, error) {
if _, err := exec.LookPath("yt-dlp"); err != nil {
return nil, fmt.Errorf("yt-dlp not found in PATH — see https://github.com/yt-dlp/yt-dlp#installation")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
args := []string{"--flat-playlist", "-j", "--socket-timeout", "15"}
if browser := ytdlCookiesFrom(); browser != "" {
args = append(args, "--cookies-from-browser", browser)
}
if start > 0 {
args = append(args, "--playlist-start", strconv.Itoa(start+1)) // yt-dlp is 1-based
}
if end > 0 {
args = append(args, "--playlist-end", strconv.Itoa(end))
}
args = append(args, pageURL)
cmd := exec.CommandContext(ctx, "yt-dlp", args...)
var stderr strings.Builder
cmd.Stderr = &stderr
stdout, err := cmd.Output()
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("yt-dlp: timed out resolving %s (30s)", pageURL)
}
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return nil, fmt.Errorf("yt-dlp: %s", msg)
}
return nil, fmt.Errorf("yt-dlp: %w", err)
}
var tracks []playlist.Track
scanner := bufio.NewScanner(strings.NewReader(string(stdout)))
// yt-dlp JSON can exceed bufio.Scanner's default 64KB token limit
// (e.g. videos with very long descriptions).
scanner.Buffer(make([]byte, 0, scannerInitBufSize), scannerMaxLineSize)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var e ytdlFlatEntry
if err := json.Unmarshal([]byte(line), &e); err != nil {
continue
}
trackURL := e.WebpageURL
if trackURL == "" {
trackURL = e.URL
}
if trackURL == "" {
continue
}
title := e.Title
if title == "" {
title = humanizeBasename(e.WebpageURLBasename)
}
if title == "" {
title = trackURL
}
artist := e.Uploader
if artist == "" {
artist = e.PlaylistUploader
}
tracks = append(tracks, playlist.Track{
Path: trackURL,
Title: title,
Artist: artist,
Stream: true,
DurationSecs: int(e.Duration),
})
}
return tracks, scanner.Err()
}
// DownloadYTDL downloads a single track via yt-dlp to the given directory
// and returns the output file path. Uses yt-dlp's default naming template.
func DownloadYTDL(pageURL, saveDir string) (string, error) {
if _, err := exec.LookPath("yt-dlp"); err != nil {
return "", fmt.Errorf("yt-dlp not found in PATH")
}
outTemplate := filepath.Join(saveDir, "%(artist,uploader)s - %(title)s.%(ext)s")
cmd := exec.Command("yt-dlp",
"-f", "bestaudio[protocol=https]/bestaudio[protocol=http]/bestaudio",
"--no-playlist",
"--print-json",
"-o", outTemplate,
pageURL)
var stderr strings.Builder
cmd.Stderr = &stderr
stdout, err := cmd.Output()
if err != nil {
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return "", fmt.Errorf("yt-dlp: %s", msg)
}
return "", fmt.Errorf("yt-dlp: %w", err)
}
var e ytdlFullEntry
if err := json.Unmarshal(stdout, &e); err != nil {
return "", fmt.Errorf("parsing yt-dlp output: %w", err)
}
if e.Filename == "" {
return "", fmt.Errorf("yt-dlp: no file downloaded for %s", pageURL)
}
return e.Filename, nil
}
// parseItunesDuration parses an <itunes:duration> value into seconds.
// Accepts "HH:MM:SS", "MM:SS", or a plain seconds string (integer or float).
// Returns 0 for any invalid or negative input.
func parseItunesDuration(s string) int {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
// parseSec handles fractional seconds (e.g. "61.5" → 61).
parseSec := func(s string) (int, error) {
f, err := strconv.ParseFloat(s, 64)
return int(f), err
}
parts := strings.Split(s, ":")
var result int
switch len(parts) {
case 1:
n, err := parseSec(parts[0])
if err != nil {
return 0
}
result = n
case 2:
m, err1 := strconv.Atoi(parts[0])
sec, err2 := parseSec(parts[1])
if err1 != nil || err2 != nil {
return 0
}
result = m*60 + sec
case 3:
h, err1 := strconv.Atoi(parts[0])
m, err2 := strconv.Atoi(parts[1])
sec, err3 := parseSec(parts[2])
if err1 != nil || err2 != nil || err3 != nil {
return 0
}
result = h*3600 + m*60 + sec
default:
return 0
}
if result < 0 {
return 0
}
return result
}
// humanizeBasename converts a URL basename like "clr-podcast-467" into "clr podcast 467".
// A trailing known audio extension (e.g. "track.mp3") is dropped so it doesn't
// leak into the title; non-media suffixes (e.g. "3.5-remix") are left intact.
func humanizeBasename(s string) string {
if ext := filepath.Ext(s); ext != "" && player.SupportedExts[strings.ToLower(ext)] {
s = strings.TrimSuffix(s, ext)
}
return strings.ReplaceAll(s, "-", " ")
}