Compare commits

...
2 Commits
15 changed files with 677 additions and 5 deletions
+4
View File
@@ -0,0 +1,4 @@
.git
.gitignore
PROJECT.md
AGENTS.md
+15
View File
@@ -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
View File
@@ -3,7 +3,7 @@ title: tver の現状
tags: tags:
- homelab - homelab
- tver - tver
status: 要件定義中 status: web 土台実装中
--- ---
# tver の現状 # tver の現状
@@ -13,7 +13,8 @@ status: 要件定義中
## 今の状態 ## 今の状態
- **要件定義中。** 実装はまだ無い - **web の土台を実装中。** Go の HTTP サーバー、TVer API クライアント、画面の空のページ、テスト、web コンテナの定義がある
- 新着一覧の画面表示、DB、worker は未実装
- リポジトリ: `https://gitea.kazu634.com/kazu634/tver.git` - リポジトリ: `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。待ち行列からダウンロードし、登録番組の新しい回を定期的に確認する | | `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 に接続する(番組登録・ダウンロード履歴・待ち行列) - DB はコンテナを持たず、NAS の共有 Postgres に接続する(番組登録・ダウンロード履歴・待ち行列)
- 保存先の NAS 共有フォルダは dockage VM に NFS でマウントし、`worker`(書き込み)と `web`(読み取り・削除)にバインドマウントする - 保存先の NAS 共有フォルダは dockage VM に NFS でマウントし、`worker`(書き込み)と `web`(読み取り・削除)にバインドマウントする
@@ -98,3 +102,9 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
- 非公開 API と yt-dlp の TVer 対応は、TVer 側の変更で予告なく壊れうる。壊れたことを検知する手段が要る - 非公開 API と yt-dlp の TVer 対応は、TVer 側の変更で予告なく壊れうる。壊れたことを検知する手段が要る
- NAS の準備(NFS 共有フォルダ、共有 Postgres へのデータベース・ユーザー作成、DSM ファイアウォール)は DSM での手作業になる。共有 Postgres の現行の公開ポートは [[homelab/docs/inventory|インフラ詳細]] に記載が無い [要確認] - NAS の準備(NFS 共有フォルダ、共有 Postgres へのデータベース・ユーザー作成、DSM ファイアウォール)は DSM での手作業になる。共有 Postgres の現行の公開ポートは [[homelab/docs/inventory|インフラ詳細]] に記載が無い [要確認]
- Go から yt-dlp の進捗を読み取る方式(`--progress-template` と `--print` で 1 行 1 JSON を出す等)を実機で確かめる [要確認] - 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 が使える環境で別途確認が必要
+24
View File
@@ -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())
}
+9
View File
@@ -0,0 +1,9 @@
services:
web:
build: .
environment:
TZ: Asia/Tokyo
WEB_ADDR: ":8080"
ports:
- "192.168.10.155:8080:8080"
restart: unless-stopped
+3
View File
@@ -0,0 +1,3 @@
module gitea.kazu634.com/kazu634/tver
go 1.27.1
+154
View File
@@ -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
}
+213
View File
@@ -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
View File
@@ -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
}
]
}
}
}
+43
View File
@@ -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
}
+63
View File
@@ -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)
}
})
}
}
+5
View File
@@ -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; }
File diff suppressed because one or more lines are too long
+13
View File
@@ -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.
+24
View File
@@ -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}}