diff --git a/backend/cmd/danime-episode/main.go b/backend/cmd/danime-episode/main.go new file mode 100644 index 0000000..7d30691 --- /dev/null +++ b/backend/cmd/danime-episode/main.go @@ -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 \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)) +} diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 171a03b..9a32f31 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -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": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 9b45839..0868a90 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -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": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 41820c9..e92fa1c 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -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. diff --git a/backend/internal/danime/danime.go b/backend/internal/danime/danime.go new file mode 100644 index 0000000..d76b28b --- /dev/null +++ b/backend/internal/danime/danime.go @@ -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 "#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) +} diff --git a/backend/internal/danime/danime_test.go b/backend/internal/danime/danime_test.go new file mode 100644 index 0000000..7cbaf70 --- /dev/null +++ b/backend/internal/danime/danime_test.go @@ -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", 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) +} diff --git a/backend/internal/http/handlers.go b/backend/internal/http/handlers.go index b8d2ef8..bbded3a 100644 --- a/backend/internal/http/handlers.go +++ b/backend/internal/http/handlers.go @@ -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`. diff --git a/backend/internal/http/handlers_test.go b/backend/internal/http/handlers_test.go index 22f4029..e48ace4 100644 --- a/backend/internal/http/handlers_test.go +++ b/backend/internal/http/handlers_test.go @@ -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{ diff --git a/backend/internal/http/types.go b/backend/internal/http/types.go index 53e2cbf..e47d507 100644 --- a/backend/internal/http/types.go +++ b/backend/internal/http/types.go @@ -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"` +}