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 & -> & 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 }