- Implemented FetchEpisode function to retrieve episode metadata from dアニメストア. - Added /api/v1/danime GET endpoint to fetch episode details by partId. - Updated Swagger documentation for the new endpoint and response structure. - Created DanimeEpisodeResponse type for API responses. - Added tests for the new functionality and handlers.
152 lines
3.8 KiB
Go
152 lines
3.8 KiB
Go
package danime
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"net/url"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// RawPartInfo mirrors the subset of fields we need from WS030101.
|
||
type RawPartInfo struct {
|
||
WorkTitle string `json:"workTitle"` // Season / series title
|
||
PartTitle string `json:"partTitle"` // Episode title
|
||
PartDispNumber string `json:"partDispNumber"` // Display episode number, e.g. "第21話" or "#21"
|
||
PartMeasure string `json:"partMeasure"` // Duration string, e.g. "23分50秒"
|
||
}
|
||
|
||
// Episode is the normalized representation we use internally and/or output as JSON.
|
||
type Episode struct {
|
||
EpNum int `json:"ep_num"`
|
||
EpTitle string `json:"ep_title"`
|
||
SeasonName string `json:"season_name"`
|
||
PlaybackLength string `json:"playback_length"` // format: "HH:MM:SS"
|
||
}
|
||
|
||
var (
|
||
baseURL = "https://animestore.docomo.ne.jp/animestore/rest/WS030101"
|
||
httpClient = &http.Client{Timeout: 10 * time.Second}
|
||
epNumRe = regexp.MustCompile(`\d+`)
|
||
hourRe = regexp.MustCompile(`(\d+)時間`)
|
||
minRe = regexp.MustCompile(`(\d+)分`)
|
||
secRe = regexp.MustCompile(`(\d+)秒`)
|
||
)
|
||
|
||
// FetchEpisode fetches metadata for a single episode (partId) and returns normalized Episode.
|
||
func FetchEpisode(ctx context.Context, partID string) (*Episode, error) {
|
||
partID = strings.TrimSpace(partID)
|
||
if partID == "" {
|
||
return nil, fmt.Errorf("partId is required")
|
||
}
|
||
|
||
u, err := url.Parse(baseURL)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("parse base url: %w", err)
|
||
}
|
||
q := u.Query()
|
||
q.Set("partId", partID)
|
||
u.RawQuery = q.Encode()
|
||
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("create request: %w", err)
|
||
}
|
||
|
||
resp, err := httpClient.Do(req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("fetching WS030101 for partId=%s: %w", partID, err)
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("WS030101 status %d for partId=%s", resp.StatusCode, partID)
|
||
}
|
||
|
||
var raw RawPartInfo
|
||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||
return nil, fmt.Errorf("decoding WS030101: %w", err)
|
||
}
|
||
|
||
ep, err := normalizeEpisode(raw)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("normalizing WS030101: %w", err)
|
||
}
|
||
return &ep, nil
|
||
}
|
||
|
||
func normalizeEpisode(raw RawPartInfo) (Episode, error) {
|
||
num := parseEpisodeNumber(raw.PartDispNumber)
|
||
length, err := parsePartMeasure(raw.PartMeasure)
|
||
if err != nil {
|
||
return Episode{}, err
|
||
}
|
||
|
||
return Episode{
|
||
EpNum: num,
|
||
EpTitle: raw.PartTitle,
|
||
SeasonName: raw.WorkTitle,
|
||
PlaybackLength: length,
|
||
}, nil
|
||
}
|
||
|
||
func parseEpisodeNumber(s string) int {
|
||
s = toHalfWidthDigits(s)
|
||
match := epNumRe.FindString(s)
|
||
if match == "" {
|
||
return 0
|
||
}
|
||
n, err := strconv.Atoi(match)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return n
|
||
}
|
||
|
||
func parsePartMeasure(s string) (string, error) {
|
||
s = strings.TrimSpace(toHalfWidthDigits(s))
|
||
if s == "" {
|
||
return "", fmt.Errorf("partMeasure missing")
|
||
}
|
||
|
||
hours := extractInt(hourRe, s)
|
||
minutes := extractInt(minRe, s)
|
||
seconds := extractInt(secRe, s)
|
||
|
||
if hours == 0 && minutes == 0 && seconds == 0 {
|
||
return "", fmt.Errorf("could not parse partMeasure %q", s)
|
||
}
|
||
|
||
dur := time.Duration(hours)*time.Hour + time.Duration(minutes)*time.Minute + time.Duration(seconds)*time.Second
|
||
totalSeconds := int(dur.Seconds())
|
||
h := totalSeconds / 3600
|
||
m := (totalSeconds % 3600) / 60
|
||
sec := totalSeconds % 60
|
||
return fmt.Sprintf("%02d:%02d:%02d", h, m, sec), nil
|
||
}
|
||
|
||
func extractInt(re *regexp.Regexp, s string) int {
|
||
m := re.FindStringSubmatch(s)
|
||
if len(m) < 2 {
|
||
return 0
|
||
}
|
||
n, err := strconv.Atoi(m[1])
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return n
|
||
}
|
||
|
||
func toHalfWidthDigits(s string) string {
|
||
return strings.Map(func(r rune) rune {
|
||
// Convert full-width digits to ASCII digits.
|
||
if r >= '0' && r <= '9' {
|
||
return r - '0' + '0'
|
||
}
|
||
return r
|
||
}, s)
|
||
}
|