// 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 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, "-", " ") }