From 1d150111ad19532b2013015d01df186c982d9eb2 Mon Sep 17 00:00:00 2001 From: malai Date: Sat, 18 Jul 2026 02:02:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Charts=20=E4=B8=8E=20LoadFeed=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20context=20=E9=80=8F=E4=BC=A0=E5=8F=96?= =?UTF-8?q?=E6=B6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ChartsContext / LoadFeedContext(原方法转发 context.Background(),兼容不变) - Charts 榜单请求与逐项 lookup 全部改 NewRequestWithContext,循环顶检查 ctx.Err() 提前中止剩余 lookup - 修复 resp 在 err 检查前解引用(网络错误/ctx 取消时必 panic) - 修复 lookup 循环 User-Agent 误设在外层请求上 Co-Authored-By: Claude Fable 5 --- model/podcast.go | 10 ++++++++-- search/itunes_search.go | 27 +++++++++++++++++++-------- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/model/podcast.go b/model/podcast.go index 3df91e7..838f075 100644 --- a/model/podcast.go +++ b/model/podcast.go @@ -63,13 +63,19 @@ type Podcast struct { 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) { + 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 { timeout = 30 * time.Second } - ctx, cancel := context.WithTimeout(context.Background(), timeout) + ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() fp := gofeed.NewParser() diff --git a/search/itunes_search.go b/search/itunes_search.go index d110a7d..227b958 100644 --- a/search/itunes_search.go +++ b/search/itunes_search.go @@ -2,6 +2,7 @@ package search import ( + "context" "encoding/json" "fmt" "io" @@ -216,17 +217,23 @@ func (p *ITunesProvider) SearchPodcasts(term string, options *SearchOptions) (*m 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并获取每个项目的底层结果 // 这会导致每个结果都有一个HTTP调用,鉴于热门播客列表更新不频繁,建议客户端缓存结果 -func (p *ITunesProvider) Charts(options *ChartsOptions) (*model.SearchResult2, error) { +func (p *ITunesProvider) ChartsContext(ctx context.Context, options *ChartsOptions) (*model.SearchResult2, error) { // 构建URL url := p.buildChartsURL(options) p.Logger.Infof("url: %s", url) // 发送请求 - req, err := http.NewRequest("GET", url, nil) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, fmt.Errorf("创建请求失败: %w", err) } @@ -237,12 +244,11 @@ func (p *ITunesProvider) Charts(options *ChartsOptions) (*model.SearchResult2, e // 发送请求 p.Limiter.Take() resp, err := p.Client.Do(req) - p.Logger.Infof("response code: %d", resp.StatusCode) - if err != nil { return nil, fmt.Errorf("发送请求失败: %w", err) } defer resp.Body.Close() + p.Logger.Infof("response code: %d", resp.StatusCode) // 检查响应状态 if resp.StatusCode != http.StatusOK { @@ -270,6 +276,11 @@ func (p *ITunesProvider) Charts(options *ChartsOptions) (*model.SearchResult2, e if chartResult.Feed.Entry != nil { cnt := 1 for _, entry := range chartResult.Feed.Entry { + // 调用方取消:停止剩余 lookup,立即返回。 + if ctx.Err() != nil { + return nil, ctx.Err() + } + // 获取播客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) p.Logger.Infof("%03d lookup url: %s", cnt, lookupURL) cnt++ - lookupReq, err := http.NewRequest("GET", lookupURL, nil) + lookupReq, err := http.NewRequestWithContext(ctx, "GET", lookupURL, nil) if err != nil { continue } - req.Header.Set("User-Agent", "podcast-search-go/0.1") + lookupReq.Header.Set("User-Agent", "podcast-search-go/0.1") p.Limiter.Take() lookupResp, err := p.Client.Do(lookupReq) - p.Logger.Infof("lookup response code: %d", lookupResp.StatusCode) if err != nil { continue } + p.Logger.Infof("lookup response code: %d", lookupResp.StatusCode) if lookupResp.StatusCode != http.StatusOK { lookupResp.Body.Close()