podcast-go

TUI podcast downloader for Apple Podcasts
Log | Files | Refs | README | LICENSE

utils.go (4298B)


      1 package podcast
      2 
      3 import (
      4 	"fmt"
      5 	"html"
      6 	"io"
      7 	"net/http"
      8 	"os"
      9 	"regexp"
     10 	"strings"
     11 )
     12 
     13 // UserAgent is used for HTTP requests. Some podcast hosts (like Acast) block
     14 // Go's default User-Agent.
     15 const UserAgent = "PodcastDownloader/1.0"
     16 
     17 // httpClient is a shared HTTP client with proper User-Agent
     18 var httpClient = &http.Client{}
     19 
     20 // httpGet performs an HTTP GET with proper User-Agent
     21 func httpGet(url string) (*http.Response, error) {
     22 	req, err := http.NewRequest("GET", url, nil)
     23 	if err != nil {
     24 		return nil, err
     25 	}
     26 	req.Header.Set("User-Agent", UserAgent)
     27 	return httpClient.Do(req)
     28 }
     29 
     30 // IsNumeric checks if a string is all digits (podcast ID)
     31 func IsNumeric(s string) bool {
     32 	for _, c := range s {
     33 		if c < '0' || c > '9' {
     34 			return false
     35 		}
     36 	}
     37 	return len(s) > 0
     38 }
     39 
     40 // SanitizeFilename removes invalid characters from a filename
     41 func SanitizeFilename(name string) string {
     42 	// Remove invalid characters
     43 	re := regexp.MustCompile(`[<>:"/\\|?*]`)
     44 	name = re.ReplaceAllString(name, "")
     45 	name = strings.TrimSpace(name)
     46 
     47 	// Limit length
     48 	if len(name) > 100 {
     49 		name = name[:100]
     50 	}
     51 
     52 	if name == "" {
     53 		return "episode"
     54 	}
     55 	return name
     56 }
     57 
     58 // ProgressCallback is called during downloads with the current progress (0.0-1.0)
     59 type ProgressCallback func(percent float64)
     60 
     61 // DownloadFile downloads a file from a URL with progress reporting
     62 // It handles text-based redirects used by some podcast hosts (like Acast)
     63 func DownloadFile(filepath string, url string, onProgress ProgressCallback) error {
     64 	// Check if already exists
     65 	if _, err := os.Stat(filepath); err == nil {
     66 		return nil
     67 	}
     68 
     69 	resp, err := httpGet(url)
     70 	if err != nil {
     71 		return err
     72 	}
     73 	defer resp.Body.Close()
     74 
     75 	// Check for HTTP errors
     76 	if resp.StatusCode >= 400 {
     77 		return fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
     78 	}
     79 
     80 	// Some podcast hosts (like Acast) return a 200 with text body containing redirect URL
     81 	// instead of using proper HTTP redirects. Detect and follow these.
     82 	if resp.ContentLength > 0 && resp.ContentLength < 1000 &&
     83 		strings.Contains(resp.Header.Get("Content-Type"), "text/plain") {
     84 		body, err := io.ReadAll(resp.Body)
     85 		if err != nil {
     86 			return err
     87 		}
     88 		bodyStr := string(body)
     89 		if strings.HasPrefix(bodyStr, "Redirecting to ") {
     90 			redirectURL := strings.TrimPrefix(bodyStr, "Redirecting to ")
     91 			redirectURL = strings.TrimSpace(redirectURL)
     92 			// Unescape HTML entities like &amp; -> &
     93 			redirectURL = html.UnescapeString(redirectURL)
     94 			return DownloadFile(filepath, redirectURL, onProgress)
     95 		}
     96 	}
     97 
     98 	out, err := os.Create(filepath)
     99 	if err != nil {
    100 		return err
    101 	}
    102 	defer out.Close()
    103 
    104 	totalSize := resp.ContentLength
    105 	downloaded := int64(0)
    106 	lastPercent := float64(0)
    107 
    108 	buf := make([]byte, 32*1024)
    109 	for {
    110 		n, err := resp.Body.Read(buf)
    111 		if n > 0 {
    112 			out.Write(buf[:n])
    113 			downloaded += int64(n)
    114 			if totalSize > 0 && onProgress != nil {
    115 				percent := float64(downloaded) / float64(totalSize)
    116 				// Only send updates every 1% to avoid flooding
    117 				if percent-lastPercent >= 0.01 || percent >= 1.0 {
    118 					lastPercent = percent
    119 					onProgress(percent)
    120 				}
    121 			}
    122 		}
    123 		if err == io.EOF {
    124 			break
    125 		}
    126 		if err != nil {
    127 			return err
    128 		}
    129 	}
    130 
    131 	return nil
    132 }
    133 
    134 // ParseEpisodeSpec parses an episode specification string and returns episode indices.
    135 // Supports: "all", "latest", single numbers "5", ranges "1-5", and comma-separated "1,3,5"
    136 func ParseEpisodeSpec(spec string, total int) []int {
    137 	if spec == "" || spec == "all" {
    138 		result := make([]int, total)
    139 		for i := range result {
    140 			result[i] = i + 1
    141 		}
    142 		return result
    143 	}
    144 
    145 	if spec == "latest" {
    146 		return []int{1}
    147 	}
    148 
    149 	var result []int
    150 	parts := strings.Split(spec, ",")
    151 	for _, part := range parts {
    152 		part = strings.TrimSpace(part)
    153 		if strings.Contains(part, "-") {
    154 			rangeParts := strings.Split(part, "-")
    155 			if len(rangeParts) == 2 {
    156 				start := parseInt(strings.TrimSpace(rangeParts[0]))
    157 				end := parseInt(strings.TrimSpace(rangeParts[1]))
    158 				if start > 0 && end > 0 {
    159 					for i := start; i <= end; i++ {
    160 						result = append(result, i)
    161 					}
    162 				}
    163 			}
    164 		} else {
    165 			if num := parseInt(part); num > 0 {
    166 				result = append(result, num)
    167 			}
    168 		}
    169 	}
    170 	return result
    171 }
    172 
    173 func parseInt(s string) int {
    174 	var n int
    175 	for _, c := range s {
    176 		if c < '0' || c > '9' {
    177 			return 0
    178 		}
    179 		n = n*10 + int(c-'0')
    180 	}
    181 	return n
    182 }