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) }