Nik Afiq 88f4ee3d4a Add Danime episode metadata fetching and related API endpoint
- 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.
2025-12-06 19:26:45 +09:00

152 lines
3.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 ""
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 >= '' && r <= '' {
return r - '' + '0'
}
return r
}, s)
}