Files
tver/internal/tver/client.go
T

172 lines
5.3 KiB
Go

package tver
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const (
DefaultBaseURL = "https://platform-api.tver.jp"
PreviewMaxSeconds = 180
)
type Session struct {
PlatformUID string `json:"platform_uid"`
PlatformToken string `json:"platform_token"`
}
type Episode struct {
ID string `json:"id"`
SeriesID string `json:"seriesID"`
SeriesTitle string `json:"seriesTitle"`
Title string `json:"title"`
BroadcasterName string `json:"broadcasterName"`
Duration int64 `json:"duration"`
EndAt int64 `json:"endAt"`
ThumbnailPath string `json:"thumbnailPath"`
BroadcastDateLabel string `json:"broadcastDateLabel"`
}
type Client struct {
baseURL *url.URL
serviceURL *url.URL
httpClient *http.Client
}
type httpStatusError struct {
statusCode int
}
func (e *httpStatusError) Error() string {
return fmt.Sprintf("HTTP %d", e.statusCode)
}
// NewClient はテスト用に API の接続先と HTTP クライアントを差し替えられる。
// 接続先が空、またはクライアントが nil の場合は本番用の既定値を使う。
func NewClient(baseURL string, httpClient *http.Client) (*Client, error) {
defaultAPI := baseURL == ""
if baseURL == "" {
baseURL = DefaultBaseURL
}
parsed, err := url.Parse(baseURL)
if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return nil, fmt.Errorf("invalid TVer API base URL")
}
if httpClient == nil {
httpClient = &http.Client{Timeout: 15 * time.Second}
}
service := parsed
if defaultAPI {
service = &url.URL{Scheme: "https", Host: "service-api.tver.jp"}
}
return &Client{baseURL: parsed, serviceURL: service, httpClient: httpClient}, nil
}
func (c *Client) CreateSession(ctx context.Context) (Session, error) {
endpoint := c.baseURL.ResolveReference(&url.URL{Path: "/v2/api/platform_users/browser/create"})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader("device_type=pc"))
if err != nil {
return Session{}, fmt.Errorf("create session request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
var response struct {
Code int `json:"code"`
Message string `json:"message"`
Result Session `json:"result"`
}
if err := c.getJSON(req, &response); err != nil {
return Session{}, fmt.Errorf("create session: %w", err)
}
if response.Code != 0 || response.Result.PlatformUID == "" || response.Result.PlatformToken == "" {
return Session{}, fmt.Errorf("create session: invalid API result (code %d)", response.Code)
}
return response.Result, nil
}
func (c *Client) ListNewer(ctx context.Context, category string, session Session) ([]Episode, error) {
if category != "drama" && category != "variety" {
return nil, fmt.Errorf("unsupported category %q", category)
}
if session.PlatformUID == "" || session.PlatformToken == "" {
return nil, errors.New("missing TVer session credentials")
}
endpoint := c.baseURL.ResolveReference(&url.URL{Path: "/service/api/v1/callNewerDetail/" + category})
query := endpoint.Query()
query.Set("platform_uid", session.PlatformUID)
query.Set("platform_token", session.PlatformToken)
endpoint.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, fmt.Errorf("newer list request: %w", err)
}
req.Header.Set("x-tver-platform-type", "web")
req.Header.Set("Origin", "https://tver.jp")
req.Header.Set("Referer", "https://tver.jp/")
var response struct {
Code int `json:"code"`
Result struct {
Contents *struct {
Contents []struct {
Type string `json:"type"`
Content Episode `json:"content"`
} `json:"contents"`
} `json:"contents"`
} `json:"result"`
}
if err := c.getJSON(req, &response); err != nil {
return nil, fmt.Errorf("newer list: %w", err)
}
if response.Code != 0 {
return nil, fmt.Errorf("newer list: API code %d", response.Code)
}
if response.Result.Contents == nil || response.Result.Contents.Contents == nil {
return nil, errors.New("newer list: missing contents")
}
var episodes []Episode
for _, item := range response.Result.Contents.Contents {
if item.Type != "episode" {
continue
}
if item.Content.ID == "" {
return nil, errors.New("newer list: episode without id")
}
episodes = append(episodes, item.Content)
}
return episodes, nil
}
func (c *Client) FetchNewer(ctx context.Context, category string) ([]Episode, error) {
session, err := c.CreateSession(ctx)
if err != nil {
return nil, err
}
return c.ListNewer(ctx, category, session)
}
func (c *Client) getJSON(req *http.Request, dst any) error {
response, err := c.httpClient.Do(req)
if err != nil {
// url.Error には platform_token を含むリクエスト URL が入るため、内部のエラーだけを返す。
var urlErr *url.Error
if errors.As(err, &urlErr) {
err = urlErr.Err
}
return fmt.Errorf("request failed: %w", err)
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return &httpStatusError{statusCode: response.StatusCode}
}
if err := json.NewDecoder(io.LimitReader(response.Body, 8<<20)).Decode(dst); err != nil {
return fmt.Errorf("decode JSON: %w", err)
}
return nil
}