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:
parent
10c54e9821
commit
88f4ee3d4a
37
backend/cmd/danime-episode/main.go
Normal file
37
backend/cmd/danime-episode/main.go
Normal 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))
|
||||||
|
}
|
||||||
@ -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": {
|
"/api/v1/shows": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "List all rows from ` + "`" + `current` + "`" + ` table.",
|
"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": {
|
"httpapi.HTTPError": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@ -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": {
|
"/api/v1/shows": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "List all rows from `current` table.",
|
"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": {
|
"httpapi.HTTPError": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@ -51,6 +51,21 @@ definitions:
|
|||||||
example: "10:00:00"
|
example: "10:00:00"
|
||||||
type: string
|
type: string
|
||||||
type: object
|
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:
|
httpapi.HTTPError:
|
||||||
properties:
|
properties:
|
||||||
error:
|
error:
|
||||||
@ -191,6 +206,33 @@ paths:
|
|||||||
summary: Set current show
|
summary: Set current show
|
||||||
tags:
|
tags:
|
||||||
- current
|
- 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:
|
/api/v1/shows:
|
||||||
delete:
|
delete:
|
||||||
description: Delete a row from `current` by ID.
|
description: Delete a row from `current` by ID.
|
||||||
|
|||||||
151
backend/internal/danime/danime.go
Normal file
151
backend/internal/danime/danime.go
Normal 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 "#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)
|
||||||
|
}
|
||||||
118
backend/internal/danime/danime_test.go
Normal file
118
backend/internal/danime/danime_test.go
Normal 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", 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)
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"watch-party-backend/internal/core/episode"
|
"watch-party-backend/internal/core/episode"
|
||||||
|
"watch-party-backend/internal/danime"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -28,6 +29,7 @@ func NewRouter(svc episode.UseCases) *gin.Engine {
|
|||||||
v1.POST("/shows", createShowHandler(svc))
|
v1.POST("/shows", createShowHandler(svc))
|
||||||
v1.GET("/shows", listShowsHandler(svc))
|
v1.GET("/shows", listShowsHandler(svc))
|
||||||
v1.DELETE("/shows", deleteShowsHandler(svc))
|
v1.DELETE("/shows", deleteShowsHandler(svc))
|
||||||
|
v1.GET("/danime", getDanimeHandler())
|
||||||
|
|
||||||
return r
|
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
|
// moveToArchiveHandler godoc
|
||||||
// @Summary Move shows to archive
|
// @Summary Move shows to archive
|
||||||
// @Description Move one or many IDs from `current` to `current_archive`.
|
// @Description Move one or many IDs from `current` to `current_archive`.
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"watch-party-backend/internal/core/episode"
|
"watch-party-backend/internal/core/episode"
|
||||||
|
"watch-party-backend/internal/danime"
|
||||||
httpapi "watch-party-backend/internal/http"
|
httpapi "watch-party-backend/internal/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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) {
|
func TestListShows_OK(t *testing.T) {
|
||||||
svc := &fakeSvc{
|
svc := &fakeSvc{
|
||||||
listRes: []episode.Episode{
|
listRes: []episode.Episode{
|
||||||
|
|||||||
@ -49,3 +49,11 @@ type CurrentResponse struct {
|
|||||||
CurrentEp bool `json:"current_ep" example:"false"`
|
CurrentEp bool `json:"current_ep" example:"false"`
|
||||||
DateCreated time.Time `json:"date_created" example:"2024-02-01T15:04:05Z"`
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user