164 lines
5.2 KiB
Go
164 lines
5.2 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
|
|
}
|
|
|
|
// 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 fmt.Errorf("HTTP %d", 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
|
|
}
|