feat: Charts 与 LoadFeed 支持 context 透传取消

- 新增 ChartsContext / LoadFeedContext(原方法转发 context.Background(),兼容不变)
- Charts 榜单请求与逐项 lookup 全部改 NewRequestWithContext,循环顶检查
  ctx.Err() 提前中止剩余 lookup
- 修复 resp 在 err 检查前解引用(网络错误/ctx 取消时必 panic)
- 修复 lookup 循环 User-Agent 误设在外层请求上

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 02:02:12 +08:00
parent 6ffa8a248b
commit 1d150111ad
2 changed files with 27 additions and 10 deletions

View File

@@ -63,13 +63,19 @@ type Podcast struct {
Medium Medium `json:"medium,omitempty"` Medium Medium `json:"medium,omitempty"`
} }
// LoadFeed 从URL加载播客RSS订阅timeout为超时时间如果timeout<=0则使用默认值30秒 // LoadFeed 从URL加载播客RSS订阅timeout为超时时间如果timeout<=0则使用默认值30秒
// 需要透传取消时请用 LoadFeedContext。
func LoadFeed(feedURL, proxy string, timeout time.Duration) (*Podcast, error) { func LoadFeed(feedURL, proxy string, timeout time.Duration) (*Podcast, error) {
return LoadFeedContext(context.Background(), feedURL, proxy, timeout)
}
// LoadFeedContext 同 LoadFeed但请求生命周期挂在调用方 ctx 上ctx 取消/超时即中断下载。
func LoadFeedContext(ctx context.Context, feedURL, proxy string, timeout time.Duration) (*Podcast, error) {
if timeout <= 0 { if timeout <= 0 {
timeout = 30 * time.Second timeout = 30 * time.Second
} }
ctx, cancel := context.WithTimeout(context.Background(), timeout) ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
fp := gofeed.NewParser() fp := gofeed.NewParser()

View File

@@ -2,6 +2,7 @@
package search package search
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -216,17 +217,23 @@ func (p *ITunesProvider) SearchPodcasts(term string, options *SearchOptions) (*m
return result, nil return result, nil
} }
// Charts 获取热门播客列表 // Charts 获取热门播客列表(无取消语义,等价于 ChartsContext(context.Background(), options))。
func (p *ITunesProvider) Charts(options *ChartsOptions) (*model.SearchResult2, error) {
return p.ChartsContext(context.Background(), options)
}
// ChartsContext 获取热门播客列表,榜单抓取与逐项 lookup 的全部请求都挂在调用方 ctx 上:
// ctx 取消即中断当前请求并停止剩余 lookup。
// 可选参数包括国家、语言、结果数量限制、是否包含限制内容和流派 // 可选参数包括国家、语言、结果数量限制、是否包含限制内容和流派
// 由于热门播客列表以feed形式返回为了与SearchResult兼容需要解析feed并获取每个项目的底层结果 // 由于热门播客列表以feed形式返回为了与SearchResult兼容需要解析feed并获取每个项目的底层结果
// 这会导致每个结果都有一个HTTP调用鉴于热门播客列表更新不频繁建议客户端缓存结果 // 这会导致每个结果都有一个HTTP调用鉴于热门播客列表更新不频繁建议客户端缓存结果
func (p *ITunesProvider) Charts(options *ChartsOptions) (*model.SearchResult2, error) { func (p *ITunesProvider) ChartsContext(ctx context.Context, options *ChartsOptions) (*model.SearchResult2, error) {
// 构建URL // 构建URL
url := p.buildChartsURL(options) url := p.buildChartsURL(options)
p.Logger.Infof("url: %s", url) p.Logger.Infof("url: %s", url)
// 发送请求 // 发送请求
req, err := http.NewRequest("GET", url, nil) req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err) return nil, fmt.Errorf("创建请求失败: %w", err)
} }
@@ -237,12 +244,11 @@ func (p *ITunesProvider) Charts(options *ChartsOptions) (*model.SearchResult2, e
// 发送请求 // 发送请求
p.Limiter.Take() p.Limiter.Take()
resp, err := p.Client.Do(req) resp, err := p.Client.Do(req)
p.Logger.Infof("response code: %d", resp.StatusCode)
if err != nil { if err != nil {
return nil, fmt.Errorf("发送请求失败: %w", err) return nil, fmt.Errorf("发送请求失败: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
p.Logger.Infof("response code: %d", resp.StatusCode)
// 检查响应状态 // 检查响应状态
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
@@ -270,6 +276,11 @@ func (p *ITunesProvider) Charts(options *ChartsOptions) (*model.SearchResult2, e
if chartResult.Feed.Entry != nil { if chartResult.Feed.Entry != nil {
cnt := 1 cnt := 1
for _, entry := range chartResult.Feed.Entry { for _, entry := range chartResult.Feed.Entry {
// 调用方取消:停止剩余 lookup立即返回。
if ctx.Err() != nil {
return nil, ctx.Err()
}
// 获取播客ID // 获取播客ID
id := entry.ID.Attributes.ID id := entry.ID.Attributes.ID
@@ -281,18 +292,18 @@ func (p *ITunesProvider) Charts(options *ChartsOptions) (*model.SearchResult2, e
lookupURL := fmt.Sprintf("%s/lookup?id=%s", p.FeedAPIEndpoint, id) lookupURL := fmt.Sprintf("%s/lookup?id=%s", p.FeedAPIEndpoint, id)
p.Logger.Infof("%03d lookup url: %s", cnt, lookupURL) p.Logger.Infof("%03d lookup url: %s", cnt, lookupURL)
cnt++ cnt++
lookupReq, err := http.NewRequest("GET", lookupURL, nil) lookupReq, err := http.NewRequestWithContext(ctx, "GET", lookupURL, nil)
if err != nil { if err != nil {
continue continue
} }
req.Header.Set("User-Agent", "podcast-search-go/0.1") lookupReq.Header.Set("User-Agent", "podcast-search-go/0.1")
p.Limiter.Take() p.Limiter.Take()
lookupResp, err := p.Client.Do(lookupReq) lookupResp, err := p.Client.Do(lookupReq)
p.Logger.Infof("lookup response code: %d", lookupResp.StatusCode)
if err != nil { if err != nil {
continue continue
} }
p.Logger.Infof("lookup response code: %d", lookupResp.StatusCode)
if lookupResp.StatusCode != http.StatusOK { if lookupResp.StatusCode != http.StatusOK {
lookupResp.Body.Close() lookupResp.Body.Close()