web コンテナの土台を追加(ステップ0) #3
@@ -0,0 +1,4 @@
|
||||
.git
|
||||
.gitignore
|
||||
PROJECT.md
|
||||
AGENTS.md
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
FROM golang:1.27.1-alpine AS build
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
COPY cmd ./cmd
|
||||
COPY internal ./internal
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /web ./cmd/web
|
||||
|
||||
FROM alpine:3.22
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
WORKDIR /app
|
||||
COPY --from=build /web /app/web
|
||||
USER 65532:65532
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/app/web"]
|
||||
+15
-5
@@ -3,7 +3,7 @@ title: tver の現状
|
||||
tags:
|
||||
- homelab
|
||||
- tver
|
||||
status: 要件定義中
|
||||
status: web 土台実装中
|
||||
---
|
||||
|
||||
# tver の現状
|
||||
@@ -13,7 +13,8 @@ status: 要件定義中
|
||||
|
||||
## 今の状態
|
||||
|
||||
- **要件定義中。** 実装はまだ無い
|
||||
- **web の土台を実装中。** Go の HTTP サーバー、TVer API クライアント、画面の空のページ、テスト、web コンテナの定義がある
|
||||
- 新着一覧の画面表示、DB、worker は未実装
|
||||
- リポジトリ: `https://gitea.kazu634.com/kazu634/tver.git`
|
||||
|
||||
## 目的
|
||||
@@ -65,11 +66,14 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
|
||||
|
||||
## 構成(案)
|
||||
|
||||
| コンテナ | 役割 |
|
||||
|---|---|
|
||||
| `web` | UI・API。新着一覧の取得、待ち行列への登録、番組登録、動画の配信(再生) |
|
||||
| コンテナ | 役割 |
|
||||
| -------- | ----------------------------------------------------- |
|
||||
| `web` | UI・API。新着一覧の取得、待ち行列への登録、番組登録、動画の配信(再生) |
|
||||
| `worker` | Go + yt-dlp + ffmpeg。待ち行列からダウンロードし、登録番組の新しい回を定期的に確認する |
|
||||
|
||||
- 現在の `web` は `/healthz` と「新着/待ち行列/登録番組/ライブラリ」の空ページを提供する。`internal/tver` はセッション発行とドラマ・バラエティの新着取得を実装済みだが、画面からはまだ呼び出さない
|
||||
- 画面は `html/template` と htmx 2.0.11 を使い、htmx は Go の `embed` から `/static/` でローカル配信する
|
||||
- `compose.yaml` は web のみを定義し、dockage VM の LAN アドレス `192.168.10.155:8080` で公開する。待ち受けは `WEB_ADDR` で変更でき、既定は `:8080`
|
||||
- DB はコンテナを持たず、NAS の共有 Postgres に接続する(番組登録・ダウンロード履歴・待ち行列)
|
||||
- 保存先の NAS 共有フォルダは dockage VM に NFS でマウントし、`worker`(書き込み)と `web`(読み取り・削除)にバインドマウントする
|
||||
|
||||
@@ -98,3 +102,9 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
|
||||
- 非公開 API と yt-dlp の TVer 対応は、TVer 側の変更で予告なく壊れうる。壊れたことを検知する手段が要る
|
||||
- NAS の準備(NFS 共有フォルダ、共有 Postgres へのデータベース・ユーザー作成、DSM ファイアウォール)は DSM での手作業になる。共有 Postgres の現行の公開ポートは [[homelab/docs/inventory|インフラ詳細]] に記載が無い [要確認]
|
||||
- Go から yt-dlp の進捗を読み取る方式(`--progress-template` と `--print` で 1 行 1 JSON を出す等)を実機で確かめる [要確認]
|
||||
|
||||
## 確かめ方
|
||||
|
||||
- `env GOCACHE=/private/tmp/tver-go-cache GOPROXY=off go test -count=1 ./...` で Go のテストを実行する。テストの TVer 応答は `internal/tver/testdata/newer_drama.json`(2026-10-03 の実応答を 3 件に縮小)と `httptest` を使い、テストから本物の TVer にはアクセスしない
|
||||
- `env GOCACHE=/private/tmp/tver-go-cache GOPROXY=off go vet ./...` で静的解析する
|
||||
- `WEB_ADDR=127.0.0.1:18080 go run ./cmd/web` で起動し、`/healthz` と `/` を確認できる。Dockerfile と Compose のビルド・実行は Docker が使える環境で別途確認が必要
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gitea.kazu634.com/kazu634/tver/internal/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
address := os.Getenv("WEB_ADDR")
|
||||
if address == "" {
|
||||
address = ":8080"
|
||||
}
|
||||
server := &http.Server{
|
||||
Addr: address,
|
||||
Handler: web.NewHandler(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
log.Printf("web listening on %s", address)
|
||||
log.Fatal(server.ListenAndServe())
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
environment:
|
||||
TZ: Asia/Tokyo
|
||||
WEB_ADDR: ":8080"
|
||||
ports:
|
||||
- "192.168.10.155:8080:8080"
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,154 @@
|
||||
package tver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const DefaultBaseURL = "https://platform-api.tver.jp"
|
||||
|
||||
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
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient はテスト用に API の接続先と HTTP クライアントを差し替えられる。
|
||||
// 接続先が空、またはクライアントが nil の場合は本番用の既定値を使う。
|
||||
func NewClient(baseURL string, httpClient *http.Client) (*Client, error) {
|
||||
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}
|
||||
}
|
||||
return &Client{baseURL: parsed, 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
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package tver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type localOnlyTransport struct {
|
||||
host string
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (tr localOnlyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Scheme != "http" || req.URL.Host != tr.host {
|
||||
return nil, fmt.Errorf("test attempted external request to %s", req.URL.Host)
|
||||
}
|
||||
return tr.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
func testClient(t *testing.T, handler http.HandlerFunc) *Client {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
serverURL, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
httpClient := server.Client()
|
||||
baseTransport := httpClient.Transport
|
||||
if baseTransport == nil {
|
||||
baseTransport = http.DefaultTransport
|
||||
}
|
||||
httpClient.Transport = localOnlyTransport{host: serverURL.Host, base: baseTransport}
|
||||
client, err := NewClient(server.URL, httpClient)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func TestTestClientRejectsExternalRequests(t *testing.T) {
|
||||
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("unexpected local request to %s", r.URL)
|
||||
})
|
||||
_, err := client.httpClient.Get(DefaultBaseURL)
|
||||
if err == nil || !strings.Contains(err.Error(), "test attempted external request") {
|
||||
t.Errorf("external request error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchNewerFromRecordedResponse(t *testing.T) {
|
||||
fixture, err := os.ReadFile("testdata/newer_drama.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var sessionCalls, newerCalls int
|
||||
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v2/api/platform_users/browser/create":
|
||||
sessionCalls++
|
||||
if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
|
||||
t.Errorf("session request method/Content-Type: %s %s", r.Method, r.Header.Get("Content-Type"))
|
||||
}
|
||||
if err := r.ParseForm(); err != nil || r.PostForm.Get("device_type") != "pc" {
|
||||
t.Errorf("session form: %v, %v", r.PostForm, err)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":0,"result":{"platform_uid":"uid-test","platform_token":"token-test"}}`))
|
||||
case "/service/api/v1/callNewerDetail/drama":
|
||||
newerCalls++
|
||||
if r.Method != http.MethodGet || r.URL.Query().Get("platform_uid") != "uid-test" || r.URL.Query().Get("platform_token") != "token-test" {
|
||||
t.Errorf("newer request method/query: %s %v", r.Method, r.URL.Query())
|
||||
}
|
||||
for name, want := range map[string]string{"x-tver-platform-type": "web", "Origin": "https://tver.jp", "Referer": "https://tver.jp/"} {
|
||||
if got := r.Header.Get(name); got != want {
|
||||
t.Errorf("header %s = %q; want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
_, _ = w.Write(fixture)
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
episodes, err := client.FetchNewer(context.Background(), "drama")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sessionCalls != 1 || newerCalls != 1 {
|
||||
t.Fatalf("calls: session=%d newer=%d", sessionCalls, newerCalls)
|
||||
}
|
||||
if len(episodes) != 3 {
|
||||
t.Fatalf("episodes = %d; want 3", len(episodes))
|
||||
}
|
||||
want := Episode{
|
||||
ID: "eps0z5761a",
|
||||
SeriesID: "sr0lhdrtdb",
|
||||
SeriesTitle: "韓国ドラマ「良いが悪い、ドンジェ」",
|
||||
Title: "#10(最終話)",
|
||||
BroadcasterName: "BS12トゥエルビ",
|
||||
Duration: 2712,
|
||||
EndAt: 1792209540,
|
||||
ThumbnailPath: "/images/content/thumbnail/episode/xlarge/eps0z5761a.jpg?v=b45ddb51",
|
||||
BroadcastDateLabel: "10月3日(土)放送分",
|
||||
}
|
||||
if episodes[0] != want {
|
||||
t.Errorf("first episode = %+v; want %+v", episodes[0], want)
|
||||
}
|
||||
if episodes[2].Duration != 60 || episodes[2].BroadcastDateLabel != "" {
|
||||
t.Errorf("third episode = %+v", episodes[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestListNewerVarietySkipsOtherTypes(t *testing.T) {
|
||||
var calls int
|
||||
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if r.URL.Path != "/service/api/v1/callNewerDetail/variety" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
}
|
||||
if r.Method != http.MethodGet || r.URL.Query().Get("platform_uid") != "u" || r.URL.Query().Get("platform_token") != "t" {
|
||||
t.Errorf("variety request method/query: %s %v", r.Method, r.URL.Query())
|
||||
}
|
||||
for name, want := range map[string]string{"x-tver-platform-type": "web", "Origin": "https://tver.jp", "Referer": "https://tver.jp/"} {
|
||||
if got := r.Header.Get(name); got != want {
|
||||
t.Errorf("variety header %s = %q; want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":0,"result":{"contents":{"contents":[{"type":"series","content":{"id":"series-1"}},{"type":"episode","content":{"id":"episode-1","duration":42}}]}}}`))
|
||||
})
|
||||
episodes, err := client.ListNewer(context.Background(), "variety", Session{PlatformUID: "u", PlatformToken: "t"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(episodes) != 1 || episodes[0].ID != "episode-1" || episodes[0].Duration != 42 {
|
||||
t.Errorf("episodes = %+v", episodes)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("variety calls = %d; want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchNewerErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sessionStatus int
|
||||
sessionBody string
|
||||
newerStatus int
|
||||
newerBody string
|
||||
want string
|
||||
}{
|
||||
{"session HTTP error", 503, "", 200, "", "create session: HTTP 503"},
|
||||
{"session invalid JSON", 200, "{", 200, "", "create session: decode JSON"},
|
||||
{"session API error", 200, `{"code":7,"result":{"platform_uid":"u","platform_token":"t"}}`, 200, "", "create session: invalid API result"},
|
||||
{"session missing credentials", 200, `{"code":0,"result":{}}`, 200, "", "create session: invalid API result"},
|
||||
{"session missing token", 200, `{"code":0,"result":{"platform_uid":"u"}}`, 200, "", "create session: invalid API result"},
|
||||
{"session missing uid", 200, `{"code":0,"result":{"platform_token":"t"}}`, 200, "", "create session: invalid API result"},
|
||||
{"newer HTTP error", 200, `{"code":0,"result":{"platform_uid":"u","platform_token":"t"}}`, 502, "", "newer list: HTTP 502"},
|
||||
{"newer invalid JSON", 200, `{"code":0,"result":{"platform_uid":"u","platform_token":"t"}}`, 200, "{", "newer list: decode JSON"},
|
||||
{"newer API error", 200, `{"code":0,"result":{"platform_uid":"u","platform_token":"t"}}`, 200, `{"code":7}`, "newer list: API code 7"},
|
||||
{"newer missing contents", 200, `{"code":0,"result":{"platform_uid":"u","platform_token":"t"}}`, 200, `{"code":0,"result":{}}`, "newer list: missing contents"},
|
||||
{"newer missing items", 200, `{"code":0,"result":{"platform_uid":"u","platform_token":"t"}}`, 200, `{"code":0,"result":{"contents":{}}}`, "newer list: missing contents"},
|
||||
{"episode missing id", 200, `{"code":0,"result":{"platform_uid":"u","platform_token":"t"}}`, 200, `{"code":0,"result":{"contents":{"contents":[{"type":"episode","content":{"title":"untitled"}}]}}}`, "newer list: episode without id"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var sessionCalls, newerCalls int
|
||||
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v2/api/platform_users/browser/create":
|
||||
sessionCalls++
|
||||
w.WriteHeader(tt.sessionStatus)
|
||||
_, _ = w.Write([]byte(tt.sessionBody))
|
||||
case "/service/api/v1/callNewerDetail/drama":
|
||||
newerCalls++
|
||||
w.WriteHeader(tt.newerStatus)
|
||||
_, _ = w.Write([]byte(tt.newerBody))
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
_, err := client.FetchNewer(context.Background(), "drama")
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Errorf("error = %v; want %q", err, tt.want)
|
||||
}
|
||||
wantNewerCalls := 1
|
||||
if strings.HasPrefix(tt.name, "session ") {
|
||||
wantNewerCalls = 0
|
||||
}
|
||||
if sessionCalls != 1 || newerCalls != wantNewerCalls {
|
||||
t.Errorf("calls: session=%d newer=%d; want session=1 newer=%d", sessionCalls, newerCalls, wantNewerCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListNewerRejectsInvalidInput(t *testing.T) {
|
||||
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("unexpected request to %s", r.URL)
|
||||
})
|
||||
if _, err := client.ListNewer(context.Background(), "news", Session{PlatformUID: "u", PlatformToken: "t"}); err == nil {
|
||||
t.Error("unsupported category accepted")
|
||||
}
|
||||
if _, err := client.ListNewer(context.Background(), "drama", Session{}); err == nil {
|
||||
t.Error("empty session accepted")
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"api_version": "",
|
||||
"code": 0,
|
||||
"message": "",
|
||||
"type": "hasharray",
|
||||
"result": {
|
||||
"contents": {
|
||||
"type": "newer",
|
||||
"content": {
|
||||
"id": "drama",
|
||||
"version": 1,
|
||||
"title": "ドラマ"
|
||||
},
|
||||
"thumbnailType": "horizontal",
|
||||
"contents": [
|
||||
{
|
||||
"type": "episode",
|
||||
"content": {
|
||||
"id": "eps0z5761a",
|
||||
"version": 4,
|
||||
"title": "#10(最終話)",
|
||||
"seriesID": "sr0lhdrtdb",
|
||||
"duration": 2712,
|
||||
"endAt": 1792209540,
|
||||
"broadcastDateLabel": "10月3日(土)放送分",
|
||||
"isNHKContent": false,
|
||||
"isSubtitle": false,
|
||||
"ribbonID": 0,
|
||||
"seriesTitle": "韓国ドラマ「良いが悪い、ドンジェ」",
|
||||
"isAvailable": true,
|
||||
"broadcasterName": "BS12トゥエルビ",
|
||||
"productionProviderName": "BS12 トゥエルビ",
|
||||
"isEndingSoon": false,
|
||||
"thumbnailPath": "/images/content/thumbnail/episode/xlarge/eps0z5761a.jpg?v=b45ddb51",
|
||||
"isChannelDefaultSeries": false
|
||||
},
|
||||
"startAt": 1791007200,
|
||||
"endAt": 1792209540
|
||||
},
|
||||
{
|
||||
"type": "episode",
|
||||
"content": {
|
||||
"id": "epo4tfi10u",
|
||||
"version": 4,
|
||||
"title": "#9",
|
||||
"seriesID": "sr0lhdrtdb",
|
||||
"duration": 2712,
|
||||
"endAt": 1792209540,
|
||||
"broadcastDateLabel": "10月3日(土)放送分",
|
||||
"isNHKContent": false,
|
||||
"isSubtitle": false,
|
||||
"ribbonID": 0,
|
||||
"seriesTitle": "韓国ドラマ「良いが悪い、ドンジェ」",
|
||||
"isAvailable": true,
|
||||
"broadcasterName": "BS12トゥエルビ",
|
||||
"productionProviderName": "BS12 トゥエルビ",
|
||||
"isEndingSoon": false,
|
||||
"thumbnailPath": "/images/content/thumbnail/episode/xlarge/epo4tfi10u.jpg?v=2d8ff444",
|
||||
"isChannelDefaultSeries": false
|
||||
},
|
||||
"startAt": 1791007200,
|
||||
"endAt": 1792209540
|
||||
},
|
||||
{
|
||||
"type": "episode",
|
||||
"content": {
|
||||
"id": "ep8cxuessg",
|
||||
"version": 6,
|
||||
"title": "【予告】10月12日(月)24時24分 ~ 放送予定 60秒PR!",
|
||||
"seriesID": "srne6nwihe",
|
||||
"duration": 60,
|
||||
"endAt": 1800282240,
|
||||
"broadcastDateLabel": "",
|
||||
"isNHKContent": false,
|
||||
"isSubtitle": false,
|
||||
"ribbonID": 1,
|
||||
"seriesTitle": "十六夜膳房 -いざよいぜんぼう-",
|
||||
"isAvailable": true,
|
||||
"broadcasterName": "日テレ",
|
||||
"productionProviderName": "日テレ",
|
||||
"isEndingSoon": false,
|
||||
"thumbnailPath": "/images/content/thumbnail/episode/xlarge/ep8cxuessg.jpg?v=58408db2",
|
||||
"isChannelDefaultSeries": false
|
||||
},
|
||||
"startAt": 1791007200,
|
||||
"endAt": 1800282240
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html static/*
|
||||
var assets embed.FS
|
||||
|
||||
type page struct {
|
||||
Title string
|
||||
}
|
||||
|
||||
func NewHandler() http.Handler {
|
||||
tmpl := template.Must(template.ParseFS(assets, "templates/*.html"))
|
||||
staticFiles, err := fs.Sub(assets, "static")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticFiles)))
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
})
|
||||
for path, title := range map[string]string{
|
||||
"GET /{$}": "新着",
|
||||
"GET /queue": "待ち行列",
|
||||
"GET /subscriptions": "登録番組",
|
||||
"GET /library": "ライブラリ",
|
||||
} {
|
||||
mux.HandleFunc(path, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.ExecuteTemplate(w, "layout", page{Title: title}); err != nil {
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
}
|
||||
return mux
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
NewHandler().ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
if response.Code != http.StatusOK || response.Body.String() != "ok\n" {
|
||||
t.Errorf("healthz: status=%d body=%q", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagesAndEmbeddedAssets(t *testing.T) {
|
||||
handler := NewHandler()
|
||||
for _, tt := range []struct{ path, title string }{
|
||||
{"/", "新着"}, {"/queue", "待ち行列"}, {"/subscriptions", "登録番組"}, {"/library", "ライブラリ"},
|
||||
} {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, tt.path, nil))
|
||||
body := response.Body.String()
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Header().Get("Content-Type"), "text/html") {
|
||||
t.Fatalf("status=%d Content-Type=%q", response.Code, response.Header().Get("Content-Type"))
|
||||
}
|
||||
for _, want := range []string{"<h1>" + tt.title + "</h1>", "新着", "待ち行列", "登録番組", "ライブラリ", `hx-boost="true"`, "/static/htmx-2.0.11.min.js"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("page missing %q", want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/static/htmx-2.0.11.min.js", nil))
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Header().Get("Content-Type"), "javascript") || response.Body.Len() < 1000 || !strings.Contains(response.Body.String(), "htmx") {
|
||||
t.Errorf("embedded htmx: status=%d Content-Type=%q bytes=%d", response.Code, response.Header().Get("Content-Type"), response.Body.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownPathsAndMethods(t *testing.T) {
|
||||
handler := NewHandler()
|
||||
for _, tt := range []struct {
|
||||
name, method, path string
|
||||
wantStatus int
|
||||
}{
|
||||
{"unknown page", http.MethodGet, "/missing", http.StatusNotFound},
|
||||
{"root prefix", http.MethodGet, "/missing/nested", http.StatusNotFound},
|
||||
{"missing static asset", http.MethodGet, "/static/missing.js", http.StatusNotFound},
|
||||
{"unsupported page method", http.MethodPost, "/queue", http.StatusMethodNotAllowed},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, httptest.NewRequest(tt.method, tt.path, nil))
|
||||
if response.Code != tt.wantStatus {
|
||||
t.Errorf("%s %s: status=%d; want %d", tt.method, tt.path, response.Code, tt.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
body { font-family: system-ui, sans-serif; margin: 0; color: #17212b; }
|
||||
header { display: flex; align-items: center; gap: 2rem; padding: 1rem 2rem; border-bottom: 1px solid #d6dee4; }
|
||||
nav { display: flex; flex-wrap: wrap; gap: 1.5rem; }
|
||||
nav a { color: #145a86; }
|
||||
main { max-width: 72rem; margin: 2rem auto; padding: 0 2rem; }
|
||||
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,13 @@
|
||||
Zero-Clause BSD
|
||||
=============
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for
|
||||
any purpose with or without fee is hereby granted.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL
|
||||
WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
|
||||
OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
|
||||
FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
|
||||
DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
|
||||
AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
|
||||
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
@@ -0,0 +1,24 @@
|
||||
{{define "layout"}}<!doctype html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} | TVer</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script src="/static/htmx-2.0.11.min.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<strong>TVer</strong>
|
||||
<nav aria-label="メイン" hx-boost="true">
|
||||
<a href="/">新着</a>
|
||||
<a href="/queue">待ち行列</a>
|
||||
<a href="/subscriptions">登録番組</a>
|
||||
<a href="/library">ライブラリ</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<h1>{{.Title}}</h1>
|
||||
</main>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
Reference in New Issue
Block a user