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.
This commit is contained in:
Nik Afiq 2025-12-06 19:26:45 +09:00
parent 10c54e9821
commit 88f4ee3d4a
9 changed files with 570 additions and 0 deletions

View File

@ -0,0 +1,37 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"watch-party-backend/internal/danime"
)
func main() {
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s <partId>\n", os.Args[0])
flag.PrintDefaults()
}
flag.Parse()
if flag.NArg() != 1 {
flag.Usage()
os.Exit(1)
}
partID := flag.Arg(0)
ep, err := danime.FetchEpisode(context.Background(), partID)
if err != nil {
log.Fatalf("fetch failed: %v", err)
}
buf, err := json.MarshalIndent(ep, "", " ")
if err != nil {
log.Fatalf("marshal failed: %v", err)
}
fmt.Println(string(buf))
}

View File

@ -143,6 +143,47 @@ const docTemplate = `{
}
}
},
"/api/v1/danime": {
"get": {
"description": "Fetch metadata from dアニメストア WS030101 by partId.",
"produces": [
"application/json"
],
"tags": [
"scraper"
],
"summary": "Fetch dアニメ episode metadata",
"parameters": [
{
"type": "string",
"description": "dアニメ partId",
"name": "part_id",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/httpapi.DanimeEpisodeResponse"
}
},
"400": {
"description": "missing part_id",
"schema": {
"$ref": "#/definitions/httpapi.HTTPError"
}
},
"502": {
"description": "scrape failed",
"schema": {
"$ref": "#/definitions/httpapi.HTTPError"
}
}
}
}
},
"/api/v1/shows": {
"get": {
"description": "List all rows from ` + "`" + `current` + "`" + ` table.",
@ -330,6 +371,27 @@ const docTemplate = `{
}
}
},
"httpapi.DanimeEpisodeResponse": {
"type": "object",
"properties": {
"ep_num": {
"type": "integer",
"example": 21
},
"ep_title": {
"type": "string",
"example": "あったんだ。確かに"
},
"playback_length": {
"type": "string",
"example": "00:23:50"
},
"season_name": {
"type": "string",
"example": "フルーツバスケット 2nd season"
}
}
},
"httpapi.HTTPError": {
"type": "object",
"properties": {

View File

@ -141,6 +141,47 @@
}
}
},
"/api/v1/danime": {
"get": {
"description": "Fetch metadata from dアニメストア WS030101 by partId.",
"produces": [
"application/json"
],
"tags": [
"scraper"
],
"summary": "Fetch dアニメ episode metadata",
"parameters": [
{
"type": "string",
"description": "dアニメ partId",
"name": "part_id",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/httpapi.DanimeEpisodeResponse"
}
},
"400": {
"description": "missing part_id",
"schema": {
"$ref": "#/definitions/httpapi.HTTPError"
}
},
"502": {
"description": "scrape failed",
"schema": {
"$ref": "#/definitions/httpapi.HTTPError"
}
}
}
}
},
"/api/v1/shows": {
"get": {
"description": "List all rows from `current` table.",
@ -328,6 +369,27 @@
}
}
},
"httpapi.DanimeEpisodeResponse": {
"type": "object",
"properties": {
"ep_num": {
"type": "integer",
"example": 21
},
"ep_title": {
"type": "string",
"example": "あったんだ。確かに"
},
"playback_length": {
"type": "string",
"example": "00:23:50"
},
"season_name": {
"type": "string",
"example": "フルーツバスケット 2nd season"
}
}
},
"httpapi.HTTPError": {
"type": "object",
"properties": {

View File

@ -51,6 +51,21 @@ definitions:
example: "10:00:00"
type: string
type: object
httpapi.DanimeEpisodeResponse:
properties:
ep_num:
example: 21
type: integer
ep_title:
example: あったんだ。確かに
type: string
playback_length:
example: "00:23:50"
type: string
season_name:
example: フルーツバスケット 2nd season
type: string
type: object
httpapi.HTTPError:
properties:
error:
@ -191,6 +206,33 @@ paths:
summary: Set current show
tags:
- current
/api/v1/danime:
get:
description: Fetch metadata from dアニメストア WS030101 by partId.
parameters:
- description: dアニメ partId
in: query
name: part_id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/httpapi.DanimeEpisodeResponse'
"400":
description: missing part_id
schema:
$ref: '#/definitions/httpapi.HTTPError'
"502":
description: scrape failed
schema:
$ref: '#/definitions/httpapi.HTTPError'
summary: Fetch dアニメ episode metadata
tags:
- scraper
/api/v1/shows:
delete:
description: Delete a row from `current` by ID.

View File

@ -0,0 +1,151 @@
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)
}

View File

@ -0,0 +1,118 @@
package danime
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"os"
"testing"
)
func TestParseEpisodeNumber(t *testing.T) {
tests := []struct {
in string
want int
}{
{"第21話", 21},
{"第02話", 2},
{"21", 21},
{"第21話「タイトル」", 21},
{"", 21},
{"話", 0},
}
for _, tt := range tests {
if got := parseEpisodeNumber(tt.in); got != tt.want {
t.Fatalf("parseEpisodeNumber(%q) = %d, want %d", tt.in, got, tt.want)
}
}
}
func TestParsePartMeasure(t *testing.T) {
tests := []struct {
in string
want string
}{
{"23分50秒", "00:23:50"},
{"1時間2分3秒", "01:02:03"},
{"5分", "00:05:00"},
{"45秒", "00:00:45"},
}
for _, tt := range tests {
got, err := parsePartMeasure(tt.in)
if err != nil {
t.Fatalf("parsePartMeasure(%q) error: %v", tt.in, err)
}
if got != tt.want {
t.Fatalf("parsePartMeasure(%q) = %s, want %s", tt.in, got, tt.want)
}
}
}
func TestNormalizeEpisode_FromFixture(t *testing.T) {
f, err := os.Open("testdata/ws030101_part_23863021.json")
if err != nil {
t.Fatalf("open fixture: %v", err)
}
defer f.Close()
var raw RawPartInfo
if err := json.NewDecoder(f).Decode(&raw); err != nil {
t.Fatalf("decode: %v", err)
}
ep, err := normalizeEpisode(raw)
if err != nil {
t.Fatalf("normalize: %v", err)
}
if ep.EpNum != 21 || ep.EpTitle == "" || ep.PlaybackLength != "00:23:50" {
t.Fatalf("unexpected episode: %+v", ep)
}
}
func TestFetchEpisode_UsesHTTPClient(t *testing.T) {
rt := roundTripperFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Query().Get("partId") != "999" {
t.Fatalf("expected partId=999, got %s", r.URL.RawQuery)
}
f, err := os.Open("testdata/ws030101_part_23863021.json")
if err != nil {
t.Fatalf("open fixture: %v", err)
}
body, err := io.ReadAll(f)
if err != nil {
t.Fatalf("read: %v", err)
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
Header: make(http.Header),
}, nil
})
mockClient := &http.Client{Transport: rt}
prevBase := baseURL
prevClient := httpClient
baseURL = "http://example.com/WS030101"
httpClient = mockClient
defer func() {
baseURL = prevBase
httpClient = prevClient
}()
ep, err := FetchEpisode(context.Background(), "999")
if err != nil {
t.Fatalf("FetchEpisode error: %v", err)
}
if ep == nil || ep.EpNum != 21 {
t.Fatalf("unexpected episode: %+v", ep)
}
}
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}

View File

@ -8,6 +8,7 @@ import (
"time"
"watch-party-backend/internal/core/episode"
"watch-party-backend/internal/danime"
"github.com/gin-gonic/gin"
)
@ -28,6 +29,7 @@ func NewRouter(svc episode.UseCases) *gin.Engine {
v1.POST("/shows", createShowHandler(svc))
v1.GET("/shows", listShowsHandler(svc))
v1.DELETE("/shows", deleteShowsHandler(svc))
v1.GET("/danime", getDanimeHandler())
return r
}
@ -157,6 +159,39 @@ func createShowHandler(svc episode.UseCases) gin.HandlerFunc {
}
}
var FetchDanimeEpisode = danime.FetchEpisode
// getDanimeHandler godoc
// @Summary Fetch dアニメ episode metadata
// @Description Fetch metadata from dアニメストア WS030101 by partId.
// @Tags scraper
// @Produce json
// @Param part_id query string true "dアニメ partId"
// @Success 200 {object} DanimeEpisodeResponse
// @Failure 400 {object} HTTPError "missing part_id"
// @Failure 502 {object} HTTPError "scrape failed"
// @Router /api/v1/danime [get]
func getDanimeHandler() gin.HandlerFunc {
return func(c *gin.Context) {
partID := strings.TrimSpace(c.Query("part_id"))
if partID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "part_id is required"})
return
}
ep, err := FetchDanimeEpisode(c.Request.Context(), partID)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "scrape failed"})
return
}
c.JSON(http.StatusOK, DanimeEpisodeResponse{
EpNum: ep.EpNum,
EpTitle: ep.EpTitle,
SeasonName: ep.SeasonName,
PlaybackLength: ep.PlaybackLength,
})
}
}
// moveToArchiveHandler godoc
// @Summary Move shows to archive
// @Description Move one or many IDs from `current` to `current_archive`.

View File

@ -11,6 +11,7 @@ import (
"time"
"watch-party-backend/internal/core/episode"
"watch-party-backend/internal/danime"
httpapi "watch-party-backend/internal/http"
"github.com/gin-gonic/gin"
@ -321,6 +322,60 @@ func TestPostShows_DefaultStartTime(t *testing.T) {
}
}
func TestGetDanime_MissingParam(t *testing.T) {
r := newRouterWithSvc(&fakeSvc{})
req := httptest.NewRequest(http.MethodGet, "/api/v1/danime", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestGetDanime_Error(t *testing.T) {
prev := httpapi.FetchDanimeEpisode
httpapi.FetchDanimeEpisode = func(ctx context.Context, partID string) (*danime.Episode, error) {
return nil, errors.New("boom")
}
defer func() { httpapi.FetchDanimeEpisode = prev }()
r := newRouterWithSvc(&fakeSvc{})
req := httptest.NewRequest(http.MethodGet, "/api/v1/danime?part_id=123", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadGateway {
t.Fatalf("expected 502, got %d", w.Code)
}
}
func TestGetDanime_OK(t *testing.T) {
prev := httpapi.FetchDanimeEpisode
httpapi.FetchDanimeEpisode = func(ctx context.Context, partID string) (*danime.Episode, error) {
return &danime.Episode{
EpNum: 21,
EpTitle: "Title",
SeasonName: "Season",
PlaybackLength: "00:23:50",
}, nil
}
defer func() { httpapi.FetchDanimeEpisode = prev }()
r := newRouterWithSvc(&fakeSvc{})
req := httptest.NewRequest(http.MethodGet, "/api/v1/danime?part_id=23863021", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var got danime.Episode
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
if got.EpNum != 21 || got.PlaybackLength != "00:23:50" {
t.Fatalf("unexpected body: %+v", got)
}
}
func TestListShows_OK(t *testing.T) {
svc := &fakeSvc{
listRes: []episode.Episode{

View File

@ -49,3 +49,11 @@ type CurrentResponse struct {
CurrentEp bool `json:"current_ep" example:"false"`
DateCreated time.Time `json:"date_created" example:"2024-02-01T15:04:05Z"`
}
// DanimeEpisodeResponse is the response body for GET /api/v1/danime.
type DanimeEpisodeResponse struct {
EpNum int `json:"ep_num" example:"21"`
EpTitle string `json:"ep_title" example:"あったんだ。確かに"`
SeasonName string `json:"season_name" example:"フルーツバスケット 2nd season"`
PlaybackLength string `json:"playback_length" example:"00:23:50"`
}