diff --git a/internal/service/bangumi_calendar.go b/internal/service/bangumi_calendar.go new file mode 100644 index 0000000..192e70d --- /dev/null +++ b/internal/service/bangumi_calendar.go @@ -0,0 +1,77 @@ +// Package service — Bangumi discovery calendar. +package service + +import ( + "context" + "strconv" + "strings" +) + +// Calendar returns Bangumi's public on-air anime calendar as a recommendation +// rail. It needs no token, but NewBangumiProvider still attaches one when set. +func (b *BangumiProvider) Calendar(ctx context.Context) ([]ExternalMediaResult, error) { + type subject struct { + ID int `json:"id"` + Name string `json:"name"` + NameCN string `json:"name_cn"` + Summary string `json:"summary"` + AirDate string `json:"air_date"` + Images struct { + Large string `json:"large"` + Common string `json:"common"` + } `json:"images"` + Rating struct { + Score float32 `json:"score"` + } `json:"rating"` + } + type day struct { + Items []subject `json:"items"` + } + var days []day + if err := b.getJSON(ctx, b.base+"/calendar", &days); err != nil { + return nil, err + } + out := make([]ExternalMediaResult, 0, 24) + seen := map[int]struct{}{} + for _, day := range days { + for _, item := range day.Items { + if _, ok := seen[item.ID]; ok { + continue + } + seen[item.ID] = struct{}{} + title := strings.TrimSpace(item.NameCN) + if title == "" { + title = strings.TrimSpace(item.Name) + } + if title == "" { + continue + } + poster := item.Images.Large + if poster == "" { + poster = item.Images.Common + } + poster = normalizeBangumiImageURL(poster) + year := 0 + if len(item.AirDate) >= 4 { + year, _ = strconv.Atoi(item.AirDate[:4]) + } + out = append(out, ExternalMediaResult{ + Source: "bangumi", + MediaType: "anime", + Title: title, + OriginalName: item.Name, + Overview: item.Summary, + PosterURL: poster, + Year: year, + Rating: item.Rating.Score, + BangumiID: item.ID, + SubscribeKeyword: buildSubscribeKeyword(title, year), + SubscribeAliases: buildSubscribeAliases(title, item.Name, year), + }) + if len(out) >= 24 { + return out, nil + } + } + } + return out, nil +} diff --git a/internal/service/discover.go b/internal/service/discover.go index 4a266c9..957276d 100644 --- a/internal/service/discover.go +++ b/internal/service/discover.go @@ -198,150 +198,3 @@ func tmdbDiscoverPath(key string) string { return "" } } - -// Discover returns public Douban movie/TV rails. Douban does not require a -// formal API key here; these are the same public web endpoints the site uses. -func (d *DoubanProvider) Discover(ctx context.Context, key string, pages ...int) ([]ExternalMediaResult, error) { - doubanType := "movie" - tag := "热门" - switch key { - case "douban_hot_movie": - doubanType = "movie" - tag = "热门" - case "douban_top_movie": - doubanType = "movie" - tag = "高分" - case "douban_hot_tv": - doubanType = "tv" - tag = "热门" - default: - return []ExternalMediaResult{}, nil - } - q := url.Values{} - q.Set("type", doubanType) - q.Set("tag", tag) - q.Set("sort", "recommend") - q.Set("page_limit", "24") - pageNumber := 1 - if len(pages) > 0 && pages[0] > 0 { - pageNumber = pages[0] - } - q.Set("page_start", strconv.Itoa((pageNumber-1)*24)) - u := "https://movie.douban.com/j/search_subjects?" + q.Encode() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - if err != nil { - return nil, err - } - d.setHeaders(req) - resp, err := d.client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("douban discover: %d", resp.StatusCode) - } - var page struct { - Subjects []struct { - ID string `json:"id"` - Title string `json:"title"` - Rate string `json:"rate"` - Cover string `json:"cover"` - URL string `json:"url"` - } `json:"subjects"` - } - if err := json.NewDecoder(resp.Body).Decode(&page); err != nil { - return nil, err - } - out := make([]ExternalMediaResult, 0, len(page.Subjects)) - mediaType := "movie" - if doubanType == "tv" { - mediaType = "tv" - } - for _, subject := range page.Subjects { - if strings.TrimSpace(subject.Title) == "" { - continue - } - rating, _ := strconv.ParseFloat(subject.Rate, 32) - out = append(out, ExternalMediaResult{ - Source: "douban", - MediaType: mediaType, - Title: subject.Title, - PosterURL: subject.Cover, - Rating: float32(rating), - DoubanID: subject.ID, - SubscribeKeyword: subject.Title, - SubscribeAliases: buildSubscribeAliases(subject.Title, "", 0), - }) - } - return out, nil -} - -// Calendar returns Bangumi's public on-air anime calendar as a recommendation -// rail. It needs no token, but NewBangumiProvider still attaches one when set. -func (b *BangumiProvider) Calendar(ctx context.Context) ([]ExternalMediaResult, error) { - type subject struct { - ID int `json:"id"` - Name string `json:"name"` - NameCN string `json:"name_cn"` - Summary string `json:"summary"` - AirDate string `json:"air_date"` - Images struct { - Large string `json:"large"` - Common string `json:"common"` - } `json:"images"` - Rating struct { - Score float32 `json:"score"` - } `json:"rating"` - } - type day struct { - Items []subject `json:"items"` - } - var days []day - if err := b.getJSON(ctx, b.base+"/calendar", &days); err != nil { - return nil, err - } - out := make([]ExternalMediaResult, 0, 24) - seen := map[int]struct{}{} - for _, day := range days { - for _, item := range day.Items { - if _, ok := seen[item.ID]; ok { - continue - } - seen[item.ID] = struct{}{} - title := strings.TrimSpace(item.NameCN) - if title == "" { - title = strings.TrimSpace(item.Name) - } - if title == "" { - continue - } - poster := item.Images.Large - if poster == "" { - poster = item.Images.Common - } - poster = normalizeBangumiImageURL(poster) - year := 0 - if len(item.AirDate) >= 4 { - year, _ = strconv.Atoi(item.AirDate[:4]) - } - out = append(out, ExternalMediaResult{ - Source: "bangumi", - MediaType: "anime", - Title: title, - OriginalName: item.Name, - Overview: item.Summary, - PosterURL: poster, - Year: year, - Rating: item.Rating.Score, - BangumiID: item.ID, - SubscribeKeyword: buildSubscribeKeyword(title, year), - SubscribeAliases: buildSubscribeAliases(title, item.Name, year), - }) - if len(out) >= 24 { - return out, nil - } - } - } - return out, nil -} diff --git a/internal/service/douban_discover.go b/internal/service/douban_discover.go new file mode 100644 index 0000000..f938762 --- /dev/null +++ b/internal/service/douban_discover.go @@ -0,0 +1,90 @@ +// Package service — Douban discovery rails. +package service + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" +) + +// Discover returns public Douban movie/TV rails. Douban does not require a +// formal API key here; these are the same public web endpoints the site uses. +func (d *DoubanProvider) Discover(ctx context.Context, key string, pages ...int) ([]ExternalMediaResult, error) { + doubanType := "movie" + tag := "热门" + switch key { + case "douban_hot_movie": + doubanType = "movie" + tag = "热门" + case "douban_top_movie": + doubanType = "movie" + tag = "高分" + case "douban_hot_tv": + doubanType = "tv" + tag = "热门" + default: + return []ExternalMediaResult{}, nil + } + q := url.Values{} + q.Set("type", doubanType) + q.Set("tag", tag) + q.Set("sort", "recommend") + q.Set("page_limit", "24") + pageNumber := 1 + if len(pages) > 0 && pages[0] > 0 { + pageNumber = pages[0] + } + q.Set("page_start", strconv.Itoa((pageNumber-1)*24)) + u := "https://movie.douban.com/j/search_subjects?" + q.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + d.setHeaders(req) + resp, err := d.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("douban discover: %d", resp.StatusCode) + } + var page struct { + Subjects []struct { + ID string `json:"id"` + Title string `json:"title"` + Rate string `json:"rate"` + Cover string `json:"cover"` + URL string `json:"url"` + } `json:"subjects"` + } + if err := json.NewDecoder(resp.Body).Decode(&page); err != nil { + return nil, err + } + out := make([]ExternalMediaResult, 0, len(page.Subjects)) + mediaType := "movie" + if doubanType == "tv" { + mediaType = "tv" + } + for _, subject := range page.Subjects { + if strings.TrimSpace(subject.Title) == "" { + continue + } + rating, _ := strconv.ParseFloat(subject.Rate, 32) + out = append(out, ExternalMediaResult{ + Source: "douban", + MediaType: mediaType, + Title: subject.Title, + PosterURL: subject.Cover, + Rating: float32(rating), + DoubanID: subject.ID, + SubscribeKeyword: subject.Title, + SubscribeAliases: buildSubscribeAliases(subject.Title, "", 0), + }) + } + return out, nil +}