From fdde34e1f0a872ace2ecffb1b98f8325748f4d43 Mon Sep 17 00:00:00 2001 From: malai Date: Fri, 28 Mar 2025 22:27:08 +0800 Subject: [PATCH] generated by Claude-3.7-Sonnet --- README.md | 87 +++++++ go.mod | 17 ++ go.sum | 37 +++ main.go | 21 ++ model/apple_podcast.go | 40 +++ model/podcast.go | 118 +++++++++ model/search.go | 45 ++++ model/transcript.go | 57 +++++ model/types.go | 150 +++++++++++ search/itunes_search.go | 480 +++++++++++++++++++++++++++++++++++ search/itunes_search_test.go | 331 ++++++++++++++++++++++++ utils/json_parser.go | 91 +++++++ utils/srt_parser.go | 107 ++++++++ 13 files changed, 1581 insertions(+) create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 model/apple_podcast.go create mode 100644 model/podcast.go create mode 100644 model/search.go create mode 100644 model/transcript.go create mode 100644 model/types.go create mode 100644 search/itunes_search.go create mode 100644 search/itunes_search_test.go create mode 100644 utils/json_parser.go create mode 100644 utils/srt_parser.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..f7b11d1 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# Podcast Search for Go + +这是一个用于搜索播客和解析播客RSS订阅的Go库。支持iTunes和PodcastIndex目录,以及章节和转录等新功能。 + +## 功能 + +- 通过iTunes和PodcastIndex搜索播客 +- 解析播客RSS订阅 +- 获取播客剧集详情 +- 支持播客章节和转录 + +## 安装 + +```bash +go get github.com/amugofjava/podcast_search/golang +``` + +## 使用方法 + +### 搜索播客 + +```go +package main + +import ( + "fmt" + "github.com/amugofjava/podcast_search/golang/search" +) + +func main() { + // 创建iTunes搜索提供者 + searchProvider := search.NewITunesProvider() + + // 搜索播客 + result, err := searchProvider.SearchPodcasts("golang", nil) + if err != nil { + fmt.Printf("搜索错误: %v\n", err) + return + } + + // 打印结果 + fmt.Printf("找到 %d 个播客\n", result.ResultCount) + for _, item := range result.Items { + fmt.Printf("标题: %s\n", item.Title) + fmt.Printf("作者: %s\n", item.Author) + fmt.Printf("Feed URL: %s\n\n", item.FeedURL) + } +} +``` + +### 加载播客订阅 + +```go +package main + +import ( + "fmt" + "github.com/amugofjava/podcast_search/golang/model" +) + +func main() { + // 加载播客订阅 + podcast, err := model.LoadFeed("https://feeds.simplecast.com/XA_851k3") + if err != nil { + fmt.Printf("加载订阅错误: %v\n", err) + return + } + + // 打印播客信息 + fmt.Printf("标题: %s\n", podcast.Title) + fmt.Printf("描述: %s\n", podcast.Description) + fmt.Printf("剧集数量: %d\n\n", len(podcast.Episodes)) + + // 打印剧集信息 + for i, episode := range podcast.Episodes { + if i >= 3 { + break // 只显示前3个剧集 + } + fmt.Printf("剧集: %s\n", episode.Title) + fmt.Printf("发布日期: %s\n\n", episode.PublicationDate) + } +} +``` + +## 许可证 + +此库使用MIT许可证。详情请参阅LICENSE文件。 \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7faa423 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module gitea.malaihome.work/ans/podcast_search + +go 1.23 + +require github.com/mmcdole/gofeed v1.3.0 + +require ( + github.com/PuerkitoBio/goquery v1.8.0 // indirect + github.com/andybalholm/cascadia v1.3.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/stretchr/testify v1.8.4 // indirect + golang.org/x/net v0.4.0 // indirect + golang.org/x/text v0.5.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c5ad212 --- /dev/null +++ b/go.sum @@ -0,0 +1,37 @@ +github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= +github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= +github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= +github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= +github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= +github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk= +github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU= +golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..c0185ef --- /dev/null +++ b/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "fmt" + + "gitea.malaihome.work/ans/podcast_search/search" +) + +func main() { + + provider := search.NewITunesProvider() + + podcast, err := provider.SearchPodcasts("golang", &search.SearchOptions{Country: "us", Language: "en-us"}) + if err != nil { + panic(err) + } + + // print podcast to json + fmt.Printf("podcast: %+v\n", podcast) + +} diff --git a/model/apple_podcast.go b/model/apple_podcast.go new file mode 100644 index 0000000..7362b97 --- /dev/null +++ b/model/apple_podcast.go @@ -0,0 +1,40 @@ +// Package model 提供播客和剧集的数据模型 +package model + +import ( + "time" +) + +// Podcast 表示从Apple Podcasts API获取的播客信息 +type ApplePodcast struct { + WrapperType string `json:"wrapperType,omitempty"` + Kind string `json:"kind,omitempty"` + CollectionID int `json:"collectionId,omitempty"` + TrackID int `json:"trackId,omitempty"` + ArtistName string `json:"artistName,omitempty"` + CollectionName string `json:"collectionName,omitempty"` + TrackName string `json:"trackName,omitempty"` + CollectionCensoredName string `json:"collectionCensoredName,omitempty"` + TrackCensoredName string `json:"trackCensoredName,omitempty"` + CollectionViewURL string `json:"collectionViewUrl,omitempty"` + FeedURL string `json:"feedUrl,omitempty"` + TrackViewURL string `json:"trackViewUrl,omitempty"` + ArtworkURL30 string `json:"artworkUrl30,omitempty"` + ArtworkURL60 string `json:"artworkUrl60,omitempty"` + ArtworkURL100 string `json:"artworkUrl100,omitempty"` + CollectionPrice float64 `json:"collectionPrice,omitempty"` + TrackPrice float64 `json:"trackPrice,omitempty"` + CollectionHDPrice float64 `json:"collectionHdPrice,omitempty"` + ReleaseDate time.Time `json:"releaseDate,omitempty"` + CollectionExplicitness string `json:"collectionExplicitness,omitempty"` + TrackExplicitness string `json:"trackExplicitness,omitempty"` + TrackCount int `json:"trackCount,omitempty"` + TrackTimeMillis int `json:"trackTimeMillis,omitempty"` + Country string `json:"country,omitempty"` + Currency string `json:"currency,omitempty"` + PrimaryGenreName string `json:"primaryGenreName,omitempty"` + ContentAdvisoryRating string `json:"contentAdvisoryRating,omitempty"` + ArtworkURL600 string `json:"artworkUrl600,omitempty"` + GenreIDs []string `json:"genreIds,omitempty"` + Genres []string `json:"genres,omitempty"` +} diff --git a/model/podcast.go b/model/podcast.go new file mode 100644 index 0000000..8b2375f --- /dev/null +++ b/model/podcast.go @@ -0,0 +1,118 @@ +// Package model 提供播客和剧集的数据模型 +package model + +import ( + "errors" + "fmt" + + "github.com/mmcdole/gofeed" +) + +// Podcast 表示一个播客及其剧集 +type Podcast struct { + // 播客的唯一标识符 + GUID string `json:"guid,omitempty"` + + // 播客的URL + URL string `json:"url,omitempty"` + + // 播客的链接 + Link string `json:"link,omitempty"` + + // 播客的标题 + Title string `json:"title,omitempty"` + + // 播客的描述 + Description string `json:"description,omitempty"` + + // 播客的图片URL + Image string `json:"image,omitempty"` + + // 版权信息 + Copyright string `json:"copyright,omitempty"` + + // 是否锁定,表示此订阅是否可以被导入到其他平台 + Locked *Locked `json:"locked,omitempty"` + + // 资金支持信息 + Funding []Funding `json:"funding,omitempty"` + + // 人员信息 + Persons []Person `json:"persons,omitempty"` + + // 价值交换信息 + Value []Value `json:"value,omitempty"` + + // 阻止标签 + Block []Block `json:"block,omitempty"` + + // 剧集列表 + Episodes []Episode `json:"episodes,omitempty"` + + // 远程项目 + RemoteItems []RemoteItem `json:"remote_items,omitempty"` + + // 媒体类型 + Medium Medium `json:"medium,omitempty"` +} + +// LoadFeed 从URL加载播客RSS订阅 +func LoadFeed(url string) (*Podcast, error) { + fp := gofeed.NewParser() + feed, err := fp.ParseURL(url) + if err != nil { + return nil, fmt.Errorf("解析播客订阅失败: %w", err) + } + + if feed == nil { + return nil, errors.New("解析播客订阅失败: 空订阅") + } + + podcast := &Podcast{ + Title: feed.Title, + Description: feed.Description, + Link: feed.Link, + Image: feed.Image.URL, + Copyright: feed.Copyright, + Episodes: make([]Episode, 0), + Funding: make([]Funding, 0), + Persons: make([]Person, 0), + Value: make([]Value, 0), + Block: make([]Block, 0), + Medium: MediumPodcast, + } + + // 解析剧集 + for _, item := range feed.Items { + episode := Episode{ + GUID: item.GUID, + Title: item.Title, + Description: item.Description, + Link: item.Link, + PublicationDate: item.PublishedParsed, + Author: item.Author.Name, + } + + // 解析媒体URL + if len(item.Enclosures) > 0 { + episode.ContentURL = item.Enclosures[0].URL + } + + // 解析剧集图片 + if item.Image != nil && item.Image.URL != "" { + episode.ImageURL = item.Image.URL + } + + // 解析时长 + if item.ITunesExt != nil && item.ITunesExt.Duration != "" { + // 简单处理,实际应该解析时间格式 + durationSec := 0 + fmt.Sscanf(item.ITunesExt.Duration, "%d", &durationSec) + episode.Duration = durationSec + } + + podcast.Episodes = append(podcast.Episodes, episode) + } + + return podcast, nil +} diff --git a/model/search.go b/model/search.go new file mode 100644 index 0000000..2a83615 --- /dev/null +++ b/model/search.go @@ -0,0 +1,45 @@ +// Package model 提供播客和剧集的数据模型 +package model + +import ( + "time" +) + +// Item 表示搜索结果中的一个播客项目 +type Item struct { + // 播客的集合ID + CollectionID int `json:"collectionId,omitempty"` + + // 播客的标题 + Title string `json:"title,omitempty"` + + // 播客的作者 + Author string `json:"author,omitempty"` + + // 播客的Feed URL + FeedURL string `json:"feedUrl,omitempty"` + + // 播客的小尺寸封面图URL + ArtworkURL string `json:"artworkUrl,omitempty"` + + // 播客的大尺寸封面图URL + ArtworkURL600 string `json:"artworkUrl600,omitempty"` + + // 播客的流派 + Genres []string `json:"genres,omitempty"` +} + +// SearchResult 表示播客搜索的结果 +type SearchResult struct { + // 结果数量 + ResultCount int `json:"resultCount,omitempty"` + + // 搜索是否成功 + Successful bool `json:"successful,omitempty"` + + // 搜索结果项目列表 + Items []Item `json:"items,omitempty"` + + // 处理时间 + ProcessedTime time.Time `json:"processedTime,omitempty"` +} diff --git a/model/transcript.go b/model/transcript.go new file mode 100644 index 0000000..1d29132 --- /dev/null +++ b/model/transcript.go @@ -0,0 +1,57 @@ +// Package model 提供播客和剧集的数据模型 +package model + +import ( + "time" +) + +// TranscriptFormat 表示转录文件的格式 +type TranscriptFormat string + +const ( + // TranscriptFormatJSON 表示JSON格式的转录 + TranscriptFormatJSON TranscriptFormat = "json" + // TranscriptFormatSubrip 表示SRT格式的转录 + TranscriptFormatSubrip TranscriptFormat = "subrip" + // TranscriptFormatUnsupported 表示不支持的格式 + TranscriptFormatUnsupported TranscriptFormat = "unsupported" +) + +// TranscriptURL 表示播客中包含的转录URL +type TranscriptURL struct { + // 转录的URL + URL string `json:"url,omitempty"` + + // 转录的类型:json或srt + Type TranscriptFormat `json:"type,omitempty"` + + // 转录的语言 + Language string `json:"language,omitempty"` + + // 如果设置为captions,表示这是一个闭合字幕文件 + Rel string `json:"rel,omitempty"` +} + +// Transcript 表示一个转录 +type Transcript struct { + // 字幕列表 + Subtitles []Subtitle `json:"subtitles,omitempty"` +} + +// Subtitle 表示转录中的一行 +type Subtitle struct { + // 字幕的索引(顺序) + Index int `json:"index,omitempty"` + + // 字幕的开始时间 + Start time.Duration `json:"start,omitempty"` + + // 字幕的结束时间 + End time.Duration `json:"end,omitempty"` + + // 字幕的文本内容 + Data string `json:"data,omitempty"` + + // 发言人(可选) + Speaker string `json:"speaker,omitempty"` +} diff --git a/model/types.go b/model/types.go new file mode 100644 index 0000000..dde2613 --- /dev/null +++ b/model/types.go @@ -0,0 +1,150 @@ +// Package model 提供播客和剧集的数据模型 +package model + +import ( + "time" +) + +// Medium 表示媒体类型 +type Medium string + +const ( + // MediumPodcast 表示播客媒体类型 + MediumPodcast Medium = "podcast" + // MediumMusic 表示音乐媒体类型 + MediumMusic Medium = "music" + // MediumAudiobook 表示有声书媒体类型 + MediumAudiobook Medium = "audiobook" +) + +// Locked 表示播客是否锁定 +type Locked struct { + // 是否锁定 + Locked bool `json:"locked,omitempty"` + // 锁定的所有者 + Owner string `json:"owner,omitempty"` +} + +// Funding 表示资金支持信息 +type Funding struct { + // 资金支持URL + URL string `json:"url,omitempty"` + // 资金支持文本 + Text string `json:"text,omitempty"` +} + +// Person 表示人员信息 +type Person struct { + // 人员名称 + Name string `json:"name,omitempty"` + // 人员角色 + Role string `json:"role,omitempty"` + // 人员组 + Group string `json:"group,omitempty"` + // 人员头像URL + Img string `json:"img,omitempty"` + // 人员URL + Href string `json:"href,omitempty"` +} + +// Value 表示价值交换信息 +type Value struct { + // 类型 + Type string `json:"type,omitempty"` + // 方法 + Method string `json:"method,omitempty"` + // 建议 + Suggested string `json:"suggested,omitempty"` + // 接收者 + Recipients []ValueRecipient `json:"recipients,omitempty"` +} + +// ValueRecipient 表示价值接收者 +type ValueRecipient struct { + // 名称 + Name string `json:"name,omitempty"` + // 类型 + Type string `json:"type,omitempty"` + // 地址 + Address string `json:"address,omitempty"` + // 分割 + Split float64 `json:"split,omitempty"` + // 自定义键 + CustomKey string `json:"customKey,omitempty"` + // 自定义值 + CustomValue string `json:"customValue,omitempty"` +} + +// Block 表示阻止标签 +type Block struct { + // 阻止的ID + ID string `json:"id,omitempty"` + // 阻止的URL + URL string `json:"url,omitempty"` +} + +// Episode 表示播客剧集 +type Episode struct { + // 剧集的唯一标识符 + GUID string `json:"guid,omitempty"` + + // 剧集的标题 + Title string `json:"title,omitempty"` + + // 剧集的描述 + Description string `json:"description,omitempty"` + + // 剧集的链接 + Link string `json:"link,omitempty"` + + // 剧集的发布日期 + PublicationDate *time.Time `json:"publicationDate,omitempty"` + + // 剧集的作者 + Author string `json:"author,omitempty"` + + // 剧集的内容URL + ContentURL string `json:"contentUrl,omitempty"` + + // 剧集的图片URL + ImageURL string `json:"imageUrl,omitempty"` + + // 剧集的时长(秒) + Duration int `json:"duration,omitempty"` + + // 剧集的转录URL列表 + TranscriptURLs []TranscriptURL `json:"transcriptUrls,omitempty"` +} + +// RemoteItem 表示远程项目 +type RemoteItem struct { + // 远程项目的唯一标识符 + GUID string `json:"guid,omitempty"` + + // 远程项目的标题 + Title string `json:"title,omitempty"` + + // 远程项目的描述 + Description string `json:"description,omitempty"` + + // 远程项目的链接 + Link string `json:"link,omitempty"` + + // 远程项目的发布日期 + PublicationDate *time.Time `json:"publicationDate,omitempty"` + + // 远程项目的作者 + Author string `json:"author,omitempty"` + + // 远程项目的内容URL + ContentURL string `json:"contentUrl,omitempty"` + + // 远程项目的图片URL + ImageURL string `json:"imageUrl,omitempty"` + + // 远程项目的时长(秒) + Duration int `json:"duration,omitempty"` + + // 远程项目的转录URL列表 + TranscriptURLs []TranscriptURL `json:"transcriptUrls,omitempty"` +} diff --git a/search/itunes_search.go b/search/itunes_search.go new file mode 100644 index 0000000..f3dd899 --- /dev/null +++ b/search/itunes_search.go @@ -0,0 +1,480 @@ +// Package search 提供播客搜索功能 +package search + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "gitea.malaihome.work/ans/podcast_search/model" +) + +// SearchProvider 定义搜索提供者接口 +type SearchProvider interface { + // SearchPodcasts 搜索播客 + SearchPodcasts(term string, options *SearchOptions) (*model.SearchResult, error) + // Charts 获取热门播客列表 + Charts(options *ChartsOptions) (*model.SearchResult, error) + // Genres 获取可用的播客流派列表 + Genres() []string +} + +// ITunesProvider 实现iTunes搜索提供者 +type ITunesProvider struct { + // 搜索API端点 + SearchAPIEndpoint string + // Feed API端点 + FeedAPIEndpoint string + // 流派映射 + GenresMap map[string]int + // HTTP客户端 + Client *http.Client +} + +// NewITunesProvider 创建新的iTunes搜索提供者 +func NewITunesProvider() *ITunesProvider { + return &ITunesProvider{ + SearchAPIEndpoint: "https://itunes.apple.com/search", + FeedAPIEndpoint: "https://itunes.apple.com", + GenresMap: initITunesGenres(), + Client: &http.Client{Timeout: 20 * time.Second}, + } +} + +// initITunesGenres 初始化iTunes流派映射 +func initITunesGenres() map[string]int { + return map[string]int{ + "": -1, + "Arts": 1301, + "Business": 1321, + "Comedy": 1303, + "Education": 1304, + "Fiction": 1483, + "Government": 1511, + "Health & Fitness": 1512, + "History": 1487, + "Kids & Family": 1305, + "Leisure": 1502, + "Music": 1301, + "News": 1489, + "Religion & Spirituality": 1314, + "Science": 1533, + "Society & Culture": 1324, + "Sports": 1545, + "TV & Film": 1309, + "Technology": 1318, + "True Crime": 1488, + } +} + +// SearchOptions 搜索选项 +type SearchOptions struct { + // 国家 + Country string + // 流派 + Genre string + // 属性 + Attribute string + // 限制结果数量 + Limit int + // 语言 + Language string + // 是否包含限制内容 + Explicit bool +} + +// ChartsOptions 热门播客选项 +type ChartsOptions struct { + // 国家 + Country string + // 语言 + Language string + // 限制结果数量 + Limit int + // 是否包含限制内容 + Explicit bool + // 流派 + Genre string + // 额外的查询参数 + QueryParams map[string]string +} + +// SearchPodcasts 搜索播客 +func (p *ITunesProvider) SearchPodcasts(term string, options *SearchOptions) (*model.SearchResult, error) { + if term == "" { + return nil, fmt.Errorf("搜索词不能为空") + } + + // 构建查询参数 + params := url.Values{} + params.Add("term", term) + params.Add("entity", "podcast") + params.Add("media", "podcast") + + if options != nil { + if options.Country != "" { + params.Add("country", options.Country) + } + + if options.Genre != "" { + if genreID, ok := p.GenresMap[options.Genre]; ok && genreID != -1 { + params.Add("genreId", strconv.Itoa(genreID)) + } + } + + if options.Attribute != "" { + params.Add("attribute", options.Attribute) + } + + if options.Limit > 0 { + params.Add("limit", strconv.Itoa(options.Limit)) + } + + if options.Language != "" { + params.Add("language", options.Language) + } + + if options.Explicit { + params.Add("explicit", "Yes") + } else { + params.Add("explicit", "No") + } + } + + // 构建URL + url := fmt.Sprintf("%s?%s", p.SearchAPIEndpoint, params.Encode()) + fmt.Printf("url: %s\n", url) + + // 发送请求 + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %w", err) + } + + // 设置User-Agent + req.Header.Set("User-Agent", "PodcastSearch-Go/0.1") + + // 发送请求 + resp, err := p.Client.Do(req) + if err != nil { + return nil, fmt.Errorf("发送请求失败: %w", err) + } + defer resp.Body.Close() + + // 检查响应状态 + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API请求失败,状态码: %d", resp.StatusCode) + } + + // 读取响应内容 + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取响应失败: %w", err) + } + fmt.Printf("body: %s\n", string(body)) + + // 解析JSON响应 + var response struct { + ResultCount int `json:"resultCount"` + Results []map[string]interface{} `json:"results"` + } + + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("解析响应失败: %w", err) + } + + // 创建搜索结果 + result := &model.SearchResult{ + ResultCount: response.ResultCount, + Successful: true, + Items: make([]model.Item, 0, len(response.Results)), + ProcessedTime: time.Now(), + } + + // 解析结果 + for _, itemData := range response.Results { + item := model.Item{} + + // 提取基本信息 + if v, ok := itemData["collectionId"]; ok { + if id, ok := v.(float64); ok { + item.CollectionID = int(id) + } + } + + if v, ok := itemData["collectionName"]; ok { + if name, ok := v.(string); ok { + item.Title = name + } + } + + if v, ok := itemData["artistName"]; ok { + if name, ok := v.(string); ok { + item.Author = name + } + } + + if v, ok := itemData["feedUrl"]; ok { + if url, ok := v.(string); ok { + item.FeedURL = url + } + } + + if v, ok := itemData["artworkUrl100"]; ok { + if url, ok := v.(string); ok { + item.ArtworkURL = url + } + } + + if v, ok := itemData["artworkUrl600"]; ok { + if url, ok := v.(string); ok { + item.ArtworkURL600 = url + } + } + + if v, ok := itemData["genres"]; ok { + if genres, ok := v.([]interface{}); ok { + for _, g := range genres { + if genre, ok := g.(string); ok { + item.Genres = append(item.Genres, genre) + } + } + } + } + + result.Items = append(result.Items, item) + } + + return result, nil +} + +// Charts 获取热门播客列表 +// 可选参数包括国家、语言、结果数量限制、是否包含限制内容和流派 +// 由于热门播客列表以feed形式返回,为了与SearchResult兼容,需要解析feed并获取每个项目的底层结果 +// 这会导致每个结果都有一个HTTP调用,鉴于热门播客列表更新不频繁,建议客户端缓存结果 +func (p *ITunesProvider) Charts(options *ChartsOptions) (*model.SearchResult, error) { + // 构建URL + url := p.buildChartsURL(options) + + // 发送请求 + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %w", err) + } + + // 设置User-Agent + req.Header.Set("User-Agent", "PodcastSearch-Go/0.1") + + // 发送请求 + resp, err := p.Client.Do(req) + if err != nil { + return nil, fmt.Errorf("发送请求失败: %w", err) + } + defer resp.Body.Close() + + // 检查响应状态 + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API请求失败,状态码: %d", resp.StatusCode) + } + + // 读取响应内容 + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取响应失败: %w", err) + } + + // 解析JSON响应 + var response struct { + Feed struct { + Entry []map[string]interface{} `json:"entry"` + } `json:"feed"` + } + + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("解析响应失败: %w", err) + } + + // 创建搜索结果 + result := &model.SearchResult{ + Successful: true, + Items: make([]model.Item, 0), + ProcessedTime: time.Now(), + } + + // 解析结果 + if response.Feed.Entry != nil { + for _, entry := range response.Feed.Entry { + // 获取播客ID + var id string + if idObj, ok := entry["id"]; ok { + if idMap, ok := idObj.(map[string]interface{}); ok { + if attrs, ok := idMap["attributes"]; ok { + if attrsMap, ok := attrs.(map[string]interface{}); ok { + if imID, ok := attrsMap["im:id"]; ok { + id = fmt.Sprintf("%v", imID) + } + } + } + } + } + + if id == "" { + continue + } + + // 获取播客标题 + // 不再使用title变量,直接在需要时获取 + if titleObj, ok := entry["title"]; ok { + // 移除未使用的titleMap变量 + if _, ok := titleObj.(map[string]interface{}); ok { + // 处理标题信息,如果需要使用 + } + } + + // 为每个播客获取详细信息 + lookupURL := fmt.Sprintf("%s/lookup?id=%s", p.FeedAPIEndpoint, id) + lookupReq, err := http.NewRequest("GET", lookupURL, nil) + if err != nil { + continue + } + + lookupReq.Header.Set("User-Agent", "PodcastSearch-Go/0.1") + lookupResp, err := p.Client.Do(lookupReq) + if err != nil { + continue + } + + if lookupResp.StatusCode != http.StatusOK { + lookupResp.Body.Close() + continue + } + + lookupBody, err := io.ReadAll(lookupResp.Body) + lookupResp.Body.Close() + if err != nil { + continue + } + + var lookupResponse struct { + ResultCount int `json:"resultCount"` + Results []map[string]interface{} `json:"results"` + } + + if err := json.Unmarshal(lookupBody, &lookupResponse); err != nil { + continue + } + + if lookupResponse.ResultCount > 0 && len(lookupResponse.Results) > 0 { + itemData := lookupResponse.Results[0] + item := model.Item{} + + // 提取基本信息 + if v, ok := itemData["collectionId"]; ok { + if id, ok := v.(float64); ok { + item.CollectionID = int(id) + } + } + + if v, ok := itemData["collectionName"]; ok { + if name, ok := v.(string); ok { + item.Title = name + } + } + + if v, ok := itemData["artistName"]; ok { + if name, ok := v.(string); ok { + item.Author = name + } + } + + if v, ok := itemData["feedUrl"]; ok { + if url, ok := v.(string); ok { + item.FeedURL = url + } + } + + if v, ok := itemData["artworkUrl100"]; ok { + if url, ok := v.(string); ok { + item.ArtworkURL = url + } + } + + if v, ok := itemData["artworkUrl600"]; ok { + if url, ok := v.(string); ok { + item.ArtworkURL600 = url + } + } + + if v, ok := itemData["genres"]; ok { + if genres, ok := v.([]interface{}); ok { + for _, g := range genres { + if genre, ok := g.(string); ok { + item.Genres = append(item.Genres, genre) + } + } + } + } + + result.Items = append(result.Items, item) + } + } + } + + result.ResultCount = len(result.Items) + return result, nil +} + +// Genres 返回可用的播客流派列表 +func (p *ITunesProvider) Genres() []string { + genres := make([]string, 0, len(p.GenresMap)) + for genre := range p.GenresMap { + if genre != "" { + genres = append(genres, genre) + } + } + return genres +} + +// buildChartsURL 构建热门播客URL +func (p *ITunesProvider) buildChartsURL(options *ChartsOptions) string { + buf := p.FeedAPIEndpoint + + // 添加国家代码 + if options.Country != "" { + buf += "/" + options.Country + } else { + buf += "/us" + } + + // 添加RSS路径和限制 + buf += "/rss/toppodcasts/limit=" + if options.Limit > 0 { + buf += strconv.Itoa(options.Limit) + } else { + buf += "20" + } + + // 添加流派 + if options.Genre != "" { + if genreID, ok := p.GenresMap[options.Genre]; ok && genreID != -1 { + buf += "/genre=" + strconv.Itoa(genreID) + } + } + + // 添加限制内容设置 + buf += "/explicit=" + if options.Explicit { + buf += "Yes" + } else { + buf += "No" + } + + // 添加JSON格式 + buf += "/json" + + return buf +} diff --git a/search/itunes_search_test.go b/search/itunes_search_test.go new file mode 100644 index 0000000..6961204 --- /dev/null +++ b/search/itunes_search_test.go @@ -0,0 +1,331 @@ +package search + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestSearchPodcasts_EmptyTerm(t *testing.T) { + // 创建iTunes搜索提供者 + provider := NewITunesProvider() + + // 使用空搜索词调用SearchPodcasts + result, err := provider.SearchPodcasts("", nil) + + // 验证返回错误 + if err == nil { + t.Error("期望返回错误,但没有") + } + + // 验证错误消息 + expectedErrMsg := "搜索词不能为空" + if err.Error() != expectedErrMsg { + t.Errorf("期望错误消息为 %q,但得到 %q", expectedErrMsg, err.Error()) + } + + // 验证结果为nil + if result != nil { + t.Errorf("期望结果为nil,但得到 %+v", result) + } +} + +func TestSearchPodcasts_SuccessfulSearch(t *testing.T) { + // 创建测试服务器 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 验证请求方法 + if r.Method != http.MethodGet { + t.Errorf("期望请求方法为GET,但得到 %s", r.Method) + } + + // 验证请求路径 + if r.URL.Path != "/search" { + t.Errorf("期望请求路径为/search,但得到 %s", r.URL.Path) + } + + // 验证查询参数 + query := r.URL.Query() + if query.Get("term") != "golang" { + t.Errorf("期望term参数为golang,但得到 %s", query.Get("term")) + } + if query.Get("entity") != "podcast" { + t.Errorf("期望entity参数为podcast,但得到 %s", query.Get("entity")) + } + if query.Get("media") != "podcast" { + t.Errorf("期望media参数为podcast,但得到 %s", query.Get("media")) + } + + // 返回模拟响应 + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + // 创建模拟响应数据 + response := map[string]interface{}{ + "resultCount": 2, + "results": []map[string]interface{}{ + { + "collectionId": 1234567890, + "collectionName": "Go编程语言", + "artistName": "Go开发者", + "feedUrl": "https://example.com/feed1", + "artworkUrl100": "https://example.com/artwork1_100.jpg", + "artworkUrl600": "https://example.com/artwork1_600.jpg", + "genres": []string{"Technology", "Education"}, + }, + { + "collectionId": 9876543210, + "collectionName": "Golang讨论", + "artistName": "Golang爱好者", + "feedUrl": "https://example.com/feed2", + "artworkUrl100": "https://example.com/artwork2_100.jpg", + "artworkUrl600": "https://example.com/artwork2_600.jpg", + "genres": []string{"Technology"}, + }, + }, + } + + // 将响应数据编码为JSON并写入响应 + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + // 创建iTunes搜索提供者,并设置测试服务器URL + provider := &ITunesProvider{ + SearchAPIEndpoint: server.URL, + FeedAPIEndpoint: "https://itunes.apple.com", + GenresMap: initITunesGenres(), + Client: &http.Client{Timeout: 20 * time.Second}, + } + + // 调用SearchPodcasts + result, err := provider.SearchPodcasts("golang", nil) + + // 验证没有错误 + if err != nil { + t.Fatalf("期望没有错误,但得到 %v", err) + } + + // 验证结果 + if result == nil { + t.Fatal("期望结果不为nil,但得到nil") + } + + // 验证结果计数 + if result.ResultCount != 2 { + t.Errorf("期望结果计数为2,但得到 %d", result.ResultCount) + } + + // 验证结果成功标志 + if !result.Successful { + t.Error("期望结果成功标志为true,但得到false") + } + + // 验证结果项目数量 + if len(result.Items) != 2 { + t.Errorf("期望结果项目数量为2,但得到 %d", len(result.Items)) + } + + // 验证第一个结果项目 + item1 := result.Items[0] + if item1.CollectionID != 1234567890 { + t.Errorf("期望第一个项目的CollectionID为1234567890,但得到 %d", item1.CollectionID) + } + if item1.Title != "Go编程语言" { + t.Errorf("期望第一个项目的Title为'Go编程语言',但得到 %s", item1.Title) + } + if item1.Author != "Go开发者" { + t.Errorf("期望第一个项目的Author为'Go开发者',但得到 %s", item1.Author) + } + if item1.FeedURL != "https://example.com/feed1" { + t.Errorf("期望第一个项目的FeedURL为'https://example.com/feed1',但得到 %s", item1.FeedURL) + } + if item1.ArtworkURL != "https://example.com/artwork1_100.jpg" { + t.Errorf("期望第一个项目的ArtworkURL为'https://example.com/artwork1_100.jpg',但得到 %s", item1.ArtworkURL) + } + if item1.ArtworkURL600 != "https://example.com/artwork1_600.jpg" { + t.Errorf("期望第一个项目的ArtworkURL600为'https://example.com/artwork1_600.jpg',但得到 %s", item1.ArtworkURL600) + } + if len(item1.Genres) != 2 || item1.Genres[0] != "Technology" || item1.Genres[1] != "Education" { + t.Errorf("期望第一个项目的Genres为[Technology, Education],但得到 %v", item1.Genres) + } + + // 验证第二个结果项目 + item2 := result.Items[1] + if item2.CollectionID != 9876543210 { + t.Errorf("期望第二个项目的CollectionID为9876543210,但得到 %d", item2.CollectionID) + } + if item2.Title != "Golang讨论" { + t.Errorf("期望第二个项目的Title为'Golang讨论',但得到 %s", item2.Title) + } +} + +func TestSearchPodcasts_WithOptions(t *testing.T) { + // 创建测试服务器 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 验证查询参数 + query := r.URL.Query() + + // 验证基本参数 + if query.Get("term") != "golang" { + t.Errorf("期望term参数为golang,但得到 %s", query.Get("term")) + } + + // 验证选项参数 + if query.Get("country") != "cn" { + t.Errorf("期望country参数为cn,但得到 %s", query.Get("country")) + } + if query.Get("genreId") != "1318" { // Technology的ID + t.Errorf("期望genreId参数为1318,但得到 %s", query.Get("genreId")) + } + if query.Get("attribute") != "titleTerm" { + t.Errorf("期望attribute参数为titleTerm,但得到 %s", query.Get("attribute")) + } + if query.Get("limit") != "10" { + t.Errorf("期望limit参数为10,但得到 %s", query.Get("limit")) + } + if query.Get("language") != "zh-cn" { + t.Errorf("期望language参数为zh-cn,但得到 %s", query.Get("language")) + } + if query.Get("explicit") != "No" { + t.Errorf("期望explicit参数为No,但得到 %s", query.Get("explicit")) + } + + // 返回模拟响应 + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + // 创建模拟响应数据 + response := map[string]interface{}{ + "resultCount": 1, + "results": []map[string]interface{}{ + { + "collectionId": 1234567890, + "collectionName": "Go编程语言", + "artistName": "Go开发者", + "feedUrl": "https://example.com/feed1", + "artworkUrl100": "https://example.com/artwork1_100.jpg", + "artworkUrl600": "https://example.com/artwork1_600.jpg", + "genres": []string{"Technology"}, + }, + }, + } + + // 将响应数据编码为JSON并写入响应 + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + // 创建iTunes搜索提供者,并设置测试服务器URL + provider := &ITunesProvider{ + SearchAPIEndpoint: server.URL, + FeedAPIEndpoint: "https://itunes.apple.com", + GenresMap: initITunesGenres(), + Client: &http.Client{Timeout: 20 * time.Second}, + } + + // 创建搜索选项 + options := &SearchOptions{ + Country: "cn", + Genre: "Technology", + Attribute: "titleTerm", + Limit: 10, + Language: "zh-cn", + Explicit: false, + } + + // 调用SearchPodcasts + result, err := provider.SearchPodcasts("golang", options) + + // 验证没有错误 + if err != nil { + t.Fatalf("期望没有错误,但得到 %v", err) + } + + // 验证结果 + if result == nil { + t.Fatal("期望结果不为nil,但得到nil") + } + + // 验证结果计数 + if result.ResultCount != 1 { + t.Errorf("期望结果计数为1,但得到 %d", result.ResultCount) + } + + // 验证结果项目数量 + if len(result.Items) != 1 { + t.Errorf("期望结果项目数量为1,但得到 %d", len(result.Items)) + } +} + +func TestSearchPodcasts_APIError(t *testing.T) { + // 创建测试服务器,返回错误状态码 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + // 创建iTunes搜索提供者,并设置测试服务器URL + provider := &ITunesProvider{ + SearchAPIEndpoint: server.URL, + FeedAPIEndpoint: "https://itunes.apple.com", + GenresMap: initITunesGenres(), + Client: &http.Client{Timeout: 20 * time.Second}, + } + + // 调用SearchPodcasts + result, err := provider.SearchPodcasts("golang", nil) + + // 验证返回错误 + if err == nil { + t.Error("期望返回错误,但没有") + } + + // 验证错误消息包含状态码 + expectedErrMsg := "API请求失败,状态码: 500" + if err.Error() != expectedErrMsg { + t.Errorf("期望错误消息包含 %q,但得到 %q", expectedErrMsg, err.Error()) + } + + // 验证结果为nil + if result != nil { + t.Errorf("期望结果为nil,但得到 %+v", result) + } +} + +func TestSearchPodcasts_InvalidJSON(t *testing.T) { + // 创建测试服务器,返回无效的JSON + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"resultCount": 1, "results": [invalid json]`)) + })) + defer server.Close() + + // 创建iTunes搜索提供者,并设置测试服务器URL + provider := &ITunesProvider{ + SearchAPIEndpoint: server.URL, + FeedAPIEndpoint: "https://itunes.apple.com", + GenresMap: initITunesGenres(), + Client: &http.Client{Timeout: 20 * time.Second}, + } + + // 调用SearchPodcasts + result, err := provider.SearchPodcasts("golang", nil) + + // 验证返回错误 + if err == nil { + t.Error("期望返回错误,但没有") + } + + // 验证错误消息包含解析失败 + if err != nil && err.Error() != "解析响应失败: invalid character 'i' looking for beginning of value" { + t.Errorf("期望错误消息包含解析失败,但得到 %q", err.Error()) + } + + // 验证结果为nil + if result != nil { + t.Errorf("期望结果为nil,但得到 %+v", result) + } +} diff --git a/utils/json_parser.go b/utils/json_parser.go new file mode 100644 index 0000000..49c1af1 --- /dev/null +++ b/utils/json_parser.go @@ -0,0 +1,91 @@ +// Package utils 提供工具函数 +package utils + +import ( + "encoding/json" + "fmt" + "time" + + "gitea.malaihome.work/ans/podcast_search/model" +) + +// JsonParser 用于解析JSON格式的转录文件 +type JsonParser struct{} + +// NewJsonParser 创建新的JSON解析器 +func NewJsonParser() *JsonParser { + return &JsonParser{} +} + +// Parse 解析JSON格式的转录文件 +func (p *JsonParser) Parse(jsonData string) (*model.Transcript, error) { + // 解析JSON + var data map[string]interface{} + if err := json.Unmarshal([]byte(jsonData), &data); err != nil { + return nil, fmt.Errorf("解析JSON失败: %w", err) + } + + // 创建转录 + transcript := &model.Transcript{ + Subtitles: make([]model.Subtitle, 0), + } + + // 解析版本 + _, ok := data["version"].(string) + if !ok { + return nil, fmt.Errorf("无效的JSON格式: 缺少版本信息") + } + + // 解析段落 + segments, ok := data["segments"].([]interface{}) + if !ok { + return nil, fmt.Errorf("无效的JSON格式: 缺少段落信息") + } + + // 解析每个段落 + for i, segment := range segments { + seg, ok := segment.(map[string]interface{}) + if !ok { + continue + } + + // 解析开始时间 + startTime, ok := seg["startTime"].(float64) + if !ok { + continue + } + + // 解析结束时间 + endTime, ok := seg["endTime"].(float64) + if !ok { + continue + } + + // 解析内容 + body, ok := seg["body"].(string) + if !ok { + continue + } + + // 解析发言人 + speaker := "" + if s, ok := seg["speaker"].(string); ok { + speaker = s + } + + // 创建字幕 + subtitle := model.Subtitle{ + Index: i, + Start: time.Duration(startTime * float64(time.Second)), + End: time.Duration(endTime * float64(time.Second)), + Data: body, + Speaker: speaker, + } + + transcript.Subtitles = append(transcript.Subtitles, subtitle) + } + + // 我们已经解析了版本信息,但Transcript结构体中没有Version字段 + // 所以这里只返回解析好的transcript对象 + return transcript, nil +} diff --git a/utils/srt_parser.go b/utils/srt_parser.go new file mode 100644 index 0000000..8bd5c4e --- /dev/null +++ b/utils/srt_parser.go @@ -0,0 +1,107 @@ +// Package utils 提供工具函数 +package utils + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "time" + + "gitea.malaihome.work/ans/podcast_search/model" +) + +// SrtParser 用于解析SRT格式的转录文件 +type SrtParser struct { + // SRT格式匹配正则表达式 + srtMatcher *regexp.Regexp + // 换行符匹配正则表达式 + newlineMatcher *regexp.Regexp +} + +// NewSrtParser 创建新的SRT解析器 +func NewSrtParser() *SrtParser { + return &SrtParser{ + srtMatcher: regexp.MustCompile( + `(\d*)\s?((\d{2}):(\d{2}):(\d{2})[,|.](\d{3})) +--> +((\d{2}):(\d{2}):(\d{2})[,|.](\d{3})).*[\r\n]+\s*([^\r\n]+(\n|\r|\n)(?:.*))`, + ), + newlineMatcher: regexp.MustCompile(`[\r\n]+`), + } +} + +// Parse 解析SRT格式的转录文件 +func (p *SrtParser) Parse(srtData string) (*model.Transcript, error) { + matches := p.srtMatcher.FindAllStringSubmatch(srtData, -1) + subtitles := make([]model.Subtitle, 0) + i := 0 + + for _, match := range matches { + i++ + + // 索引可能存在也可能不存在 + index := i + if match[1] != "" { + parsedIndex, err := strconv.Atoi(match[1]) + if err == nil { + index = parsedIndex + } + } + + // 解析开始时间 + startTimeHours, _ := strconv.Atoi(match[3]) + startTimeMinutes, _ := strconv.Atoi(match[4]) + startTimeSeconds, _ := strconv.Atoi(match[5]) + startTimeMilliseconds, _ := strconv.Atoi(match[6]) + + // 解析结束时间 + endTimeHours, _ := strconv.Atoi(match[8]) + endTimeMinutes, _ := strconv.Atoi(match[9]) + endTimeSeconds, _ := strconv.Atoi(match[10]) + endTimeMilliseconds, _ := strconv.Atoi(match[11]) + + // 计算开始和结束时间的Duration + startDuration := time.Duration(startTimeHours)*time.Hour + + time.Duration(startTimeMinutes)*time.Minute + + time.Duration(startTimeSeconds)*time.Second + + time.Duration(startTimeMilliseconds)*time.Millisecond + + endDuration := time.Duration(endTimeHours)*time.Hour + + time.Duration(endTimeMinutes)*time.Minute + + time.Duration(endTimeSeconds)*time.Second + + time.Duration(endTimeMilliseconds)*time.Millisecond + + // 解析文本内容 + textLines := p.newlineMatcher.Split(match[12], -1) + text := "" + + // 处理文本行 + for _, line := range textLines { + if text == "" { + text = line + } else if strings.HasSuffix(text, " ") || strings.HasPrefix(line, " ") { + text += line + } else { + text += " " + line + } + } + + // 创建字幕 + subtitle := model.Subtitle{ + Index: index, + Start: startDuration, + End: endDuration, + Data: strings.TrimSpace(text), + Speaker: "", // SRT格式通常不包含发言人信息 + } + + subtitles = append(subtitles, subtitle) + } + + if len(subtitles) == 0 { + return nil, fmt.Errorf("解析SRT失败: 未找到有效的字幕") + } + + return &model.Transcript{ + Subtitles: subtitles, + }, nil +}