番組登録を追加(登録時の過去回の選択、一時停止・再開・解除、既知の回の記録)
This commit is contained in:
+17
-5
@@ -17,9 +17,10 @@ import (
|
||||
var assets embed.FS
|
||||
|
||||
type page struct {
|
||||
Title string
|
||||
Newer *newerView
|
||||
Queue *queueView
|
||||
Title string
|
||||
Newer *newerView
|
||||
Queue *queueView
|
||||
Subscriptions *subscriptionsView
|
||||
}
|
||||
|
||||
type JobStore interface {
|
||||
@@ -30,6 +31,11 @@ type JobStore interface {
|
||||
Retry(context.Context, int64) error
|
||||
RetryEpisode(context.Context, string) error
|
||||
CancelQueued(context.Context, int64) error
|
||||
SubscribedSeries(context.Context, []string) (map[string]bool, error)
|
||||
RegisterSubscription(context.Context, string, string, []store.Episode, bool) (bool, error)
|
||||
ListSubscriptions(context.Context) ([]store.Subscription, error)
|
||||
SetSubscriptionEnabled(context.Context, string, bool) error
|
||||
DeleteSubscription(context.Context, string) (bool, error)
|
||||
}
|
||||
|
||||
type server struct {
|
||||
@@ -68,9 +74,15 @@ func NewHandler(fetcher NewerFetcher, jobs JobStore, now func() time.Time, logge
|
||||
mux.HandleFunc("GET /queue/list", s.queueFragment)
|
||||
mux.HandleFunc("POST /jobs/{id}/retry", s.retryJob)
|
||||
mux.HandleFunc("POST /jobs/{id}/cancel", s.cancelJob)
|
||||
mux.HandleFunc("GET /subscriptions", s.subscriptionsPage)
|
||||
mux.HandleFunc("GET /subscriptions/list", s.subscriptionsFragment)
|
||||
mux.HandleFunc("GET /subscriptions/choice", s.subscriptionChoice)
|
||||
mux.HandleFunc("POST /subscriptions/register", s.registerSubscription)
|
||||
mux.HandleFunc("POST /subscriptions/{id}/pause", s.pauseSubscription)
|
||||
mux.HandleFunc("POST /subscriptions/{id}/resume", s.resumeSubscription)
|
||||
mux.HandleFunc("POST /subscriptions/{id}/delete", s.deleteSubscription)
|
||||
for path, title := range map[string]string{
|
||||
"GET /subscriptions": "登録番組",
|
||||
"GET /library": "ライブラリ",
|
||||
"GET /library": "ライブラリ",
|
||||
} {
|
||||
mux.HandleFunc(path, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
@@ -22,6 +22,7 @@ var platformTokenPattern = regexp.MustCompile(`(?i)(platform_token=)[^&\s]+`)
|
||||
|
||||
type NewerFetcher interface {
|
||||
FetchNewer(context.Context, string) ([]tver.Episode, error)
|
||||
FetchSeriesEpisodes(context.Context, string) ([]tver.Episode, error)
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
@@ -36,6 +37,8 @@ type newerOptions struct {
|
||||
|
||||
type episodeView struct {
|
||||
ID string
|
||||
SeriesID string
|
||||
SubscriptionAction subscriptionActionView
|
||||
Genre string
|
||||
Status string
|
||||
StatusText string
|
||||
@@ -150,10 +153,27 @@ func (s *server) newerView(ctx context.Context, options newerOptions) newerView
|
||||
view.ErrorText = "状態を取得できませんでした"
|
||||
return view
|
||||
}
|
||||
seriesIDs := make([]string, 0, len(visible))
|
||||
seenSeries := make(map[string]bool)
|
||||
for _, episode := range visible {
|
||||
if episode.SeriesID != "" && !seenSeries[episode.SeriesID] {
|
||||
seriesIDs = append(seriesIDs, episode.SeriesID)
|
||||
seenSeries[episode.SeriesID] = true
|
||||
}
|
||||
}
|
||||
subscribed, err := s.jobs.SubscribedSeries(ctx, seriesIDs)
|
||||
if err != nil {
|
||||
s.logger.Printf("登録番組の状態取得に失敗: %v", err)
|
||||
view.Failed = true
|
||||
view.ErrorText = "登録番組の状態を取得できませんでした"
|
||||
return view
|
||||
}
|
||||
for _, episode := range visible {
|
||||
endAt := time.Unix(episode.EndAt, 0).In(s.zone)
|
||||
view.Episodes = append(view.Episodes, episodeView{
|
||||
ID: episode.ID,
|
||||
SeriesID: episode.SeriesID,
|
||||
SubscriptionAction: subscriptionActionView{ID: episode.ID, Genre: options.genre, Subscribed: subscribed[episode.SeriesID]},
|
||||
Genre: options.genre,
|
||||
Status: statuses[episode.ID],
|
||||
StatusText: statusText(statuses[episode.ID]),
|
||||
|
||||
@@ -20,6 +20,9 @@ type fakeFetcher struct {
|
||||
episodesByGenre map[string][]tver.Episode
|
||||
err error
|
||||
calls []string
|
||||
seriesEpisodes []tver.Episode
|
||||
seriesErr error
|
||||
seriesCalls []string
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) FetchNewer(_ context.Context, genre string) ([]tver.Episode, error) {
|
||||
@@ -30,6 +33,11 @@ func (f *fakeFetcher) FetchNewer(_ context.Context, genre string) ([]tver.Episod
|
||||
return f.episodes, f.err
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) FetchSeriesEpisodes(_ context.Context, seriesID string) ([]tver.Episode, error) {
|
||||
f.seriesCalls = append(f.seriesCalls, seriesID)
|
||||
return f.seriesEpisodes, f.seriesErr
|
||||
}
|
||||
|
||||
func newTestHandler(fetcher *fakeFetcher, now func() time.Time) http.Handler {
|
||||
return newTestHandlerWithStore(fetcher, &fakeJobStore{}, now)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ main { max-width: 72rem; margin: 2rem auto; padding: 0 2rem; }
|
||||
.episode-copy h2 { font-size: 1.1rem; margin: 0 0 .4rem; }
|
||||
.episode-copy p { margin: .3rem 0; }
|
||||
.episode-action { padding: 0 .8rem .8rem; }
|
||||
.subscription-action { padding: 0 .8rem .8rem; }
|
||||
.subscription-action button, .subscription-item button { font: inherit; padding: .35rem .7rem; }
|
||||
.subscription-item { border: 1px solid #d6dee4; border-radius: .5rem; margin: .75rem 0; padding: .8rem 1rem; }
|
||||
.subscription-item form { display: inline-block; margin-right: .5rem; }
|
||||
.episode-action form { display: inline; }
|
||||
.episode-action button, .queue-job button { font: inherit; padding: .35rem .7rem; }
|
||||
.queue-job { border: 1px solid #d6dee4; border-radius: .5rem; margin: .75rem 0; padding: .8rem 1rem; }
|
||||
|
||||
@@ -11,12 +11,19 @@ import (
|
||||
|
||||
type fakeJobStore struct {
|
||||
jobs map[string]store.Job
|
||||
subscriptions map[string]store.Subscription
|
||||
nextID int64
|
||||
lastEpisode store.Episode
|
||||
enqueueCalls, statusCalls int
|
||||
statusIDs []string
|
||||
listStatuses []string
|
||||
listLimits []int
|
||||
registerCalls int
|
||||
registerEpisodes []store.Episode
|
||||
registerDownload bool
|
||||
registerErr error
|
||||
subscribedCalls int
|
||||
subscribedIDs []string
|
||||
err error
|
||||
}
|
||||
|
||||
@@ -130,3 +137,78 @@ func (f *fakeJobStore) CancelQueued(ctx context.Context, id int64) error {
|
||||
}
|
||||
|
||||
var _ JobStore = (*fakeJobStore)(nil)
|
||||
|
||||
func (f *fakeJobStore) SubscribedSeries(_ context.Context, ids []string) (map[string]bool, error) {
|
||||
f.subscribedCalls++
|
||||
f.subscribedIDs = append([]string(nil), ids...)
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
result := make(map[string]bool)
|
||||
for _, id := range ids {
|
||||
_, result[id] = f.subscriptions[id]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (f *fakeJobStore) RegisterSubscription(_ context.Context, seriesID, title string, episodes []store.Episode, download bool) (bool, error) {
|
||||
f.registerCalls++
|
||||
f.registerEpisodes = append([]store.Episode(nil), episodes...)
|
||||
f.registerDownload = download
|
||||
if f.registerErr != nil {
|
||||
return false, f.registerErr
|
||||
}
|
||||
if f.err != nil {
|
||||
return false, f.err
|
||||
}
|
||||
if f.subscriptions == nil {
|
||||
f.subscriptions = make(map[string]store.Subscription)
|
||||
}
|
||||
if _, exists := f.subscriptions[seriesID]; exists {
|
||||
return false, nil
|
||||
}
|
||||
var count int64
|
||||
if download {
|
||||
count = int64(len(episodes))
|
||||
}
|
||||
checked := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)
|
||||
f.subscriptions[seriesID] = store.Subscription{SeriesID: seriesID, SeriesTitle: title,
|
||||
Enabled: true, CreatedAt: checked, LastCheckedAt: &checked, JobCount: count}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (f *fakeJobStore) ListSubscriptions(_ context.Context) ([]store.Subscription, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
var result []store.Subscription
|
||||
for _, sub := range f.subscriptions {
|
||||
result = append(result, sub)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].SeriesID < result[j].SeriesID })
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (f *fakeJobStore) SetSubscriptionEnabled(_ context.Context, seriesID string, enabled bool) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
sub, exists := f.subscriptions[seriesID]
|
||||
if !exists {
|
||||
return store.ErrInvalidTransition
|
||||
}
|
||||
sub.Enabled = enabled
|
||||
f.subscriptions[seriesID] = sub
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeJobStore) DeleteSubscription(_ context.Context, seriesID string) (bool, error) {
|
||||
if f.err != nil {
|
||||
return false, f.err
|
||||
}
|
||||
if _, exists := f.subscriptions[seriesID]; !exists {
|
||||
return false, nil
|
||||
}
|
||||
delete(f.subscriptions, seriesID)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gitea.kazu634.com/kazu634/tver/internal/store"
|
||||
)
|
||||
|
||||
type subscriptionActionView struct {
|
||||
ID, Genre, ErrorText string
|
||||
Subscribed, Choosing, DownloadExisting bool
|
||||
}
|
||||
|
||||
type subscriptionView struct {
|
||||
ID, Title, CreatedAtText, LastCheckedText string
|
||||
Enabled bool
|
||||
Jobs int64
|
||||
}
|
||||
|
||||
type subscriptionsView struct {
|
||||
Items []subscriptionView
|
||||
}
|
||||
|
||||
func (s *server) subscriptionChoice(w http.ResponseWriter, r *http.Request) {
|
||||
genre, id, ok := episodeForm(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ep, found := s.cachedEpisode(genre, id)
|
||||
if !found || ep.SeriesID == "" {
|
||||
http.Error(w, "新着キャッシュに番組がありません。新着を再表示してください", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
registered, err := s.isSubscribed(r.Context(), ep.SeriesID)
|
||||
if err != nil {
|
||||
s.logger.Printf("番組の登録状態取得に失敗: %v", err)
|
||||
http.Error(w, "登録状態を取得できませんでした", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if registered {
|
||||
s.subscriptionRegisteredResponse(w, r, id, genre)
|
||||
return
|
||||
}
|
||||
s.render(w, "subscription_action", subscriptionActionView{ID: id, Genre: genre, Choosing: true})
|
||||
}
|
||||
|
||||
func (s *server) registerSubscription(w http.ResponseWriter, r *http.Request) {
|
||||
genre, id, ok := episodeForm(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
choice := r.Form.Get("download_existing")
|
||||
if choice != "" && choice != "1" {
|
||||
http.Error(w, "公開中の回の選択が不正です", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ep, found := s.cachedEpisode(genre, id)
|
||||
if !found || ep.SeriesID == "" || ep.SeriesTitle == "" {
|
||||
http.Error(w, "新着キャッシュに番組がありません。新着を再表示してください", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
registered, err := s.isSubscribed(r.Context(), ep.SeriesID)
|
||||
if err != nil {
|
||||
s.logger.Printf("番組の登録状態取得に失敗: %v", err)
|
||||
http.Error(w, "登録状態を取得できませんでした", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if registered {
|
||||
s.subscriptionRegisteredResponse(w, r, id, genre)
|
||||
return
|
||||
}
|
||||
episodes, err := s.fetcher.FetchSeriesEpisodes(r.Context(), ep.SeriesID)
|
||||
if err != nil {
|
||||
s.logger.Printf("番組の公開中エピソード取得に失敗: %s", platformTokenPattern.ReplaceAllString(err.Error(), "${1}[REDACTED]"))
|
||||
s.render(w, "subscription_action", subscriptionActionView{ID: id, Genre: genre, Choosing: true, DownloadExisting: choice == "1", ErrorText: "公開中の回を取得できませんでした。再試行してください"})
|
||||
return
|
||||
}
|
||||
items := make([]store.Episode, 0, len(episodes))
|
||||
for _, episode := range episodes {
|
||||
items = append(items, storeEpisode(episode))
|
||||
}
|
||||
_, err = s.jobs.RegisterSubscription(r.Context(), ep.SeriesID, ep.SeriesTitle, items, choice == "1")
|
||||
if err != nil {
|
||||
s.logger.Printf("番組登録に失敗: %v", err)
|
||||
s.render(w, "subscription_action", subscriptionActionView{ID: id, Genre: genre, Choosing: true, DownloadExisting: choice == "1", ErrorText: "番組を登録できませんでした。再試行してください"})
|
||||
return
|
||||
}
|
||||
s.subscriptionRegisteredResponse(w, r, id, genre)
|
||||
}
|
||||
|
||||
func (s *server) subscriptionRegisteredResponse(w http.ResponseWriter, r *http.Request, id, genre string) {
|
||||
if r.Header.Get("HX-Request") != "true" && r.Method == http.MethodPost {
|
||||
http.Redirect(w, r, "/?genre="+genre, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.render(w, "subscription_action", subscriptionActionView{ID: id, Genre: genre, Subscribed: true})
|
||||
}
|
||||
|
||||
func (s *server) isSubscribed(ctx context.Context, seriesID string) (bool, error) {
|
||||
result, err := s.jobs.SubscribedSeries(ctx, []string{seriesID})
|
||||
return result[seriesID], err
|
||||
}
|
||||
|
||||
func (s *server) subscriptionsPage(w http.ResponseWriter, r *http.Request) {
|
||||
view, err := s.loadSubscriptions(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Printf("登録番組の取得に失敗: %v", err)
|
||||
http.Error(w, "登録番組を取得できませんでした", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.render(w, "layout", page{Title: "登録番組", Subscriptions: &view})
|
||||
}
|
||||
|
||||
func (s *server) subscriptionsFragment(w http.ResponseWriter, r *http.Request) {
|
||||
view, err := s.loadSubscriptions(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Printf("登録番組の取得に失敗: %v", err)
|
||||
http.Error(w, "登録番組を取得できませんでした", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.render(w, "subscriptions_list", view)
|
||||
}
|
||||
|
||||
func (s *server) loadSubscriptions(ctx context.Context) (subscriptionsView, error) {
|
||||
subs, err := s.jobs.ListSubscriptions(ctx)
|
||||
if err != nil {
|
||||
return subscriptionsView{}, err
|
||||
}
|
||||
view := subscriptionsView{Items: make([]subscriptionView, 0, len(subs))}
|
||||
for _, sub := range subs {
|
||||
item := subscriptionView{ID: sub.SeriesID, Title: sub.SeriesTitle,
|
||||
CreatedAtText: sub.CreatedAt.In(s.zone).Format("2006-01-02 15:04 MST"),
|
||||
LastCheckedText: "未確認", Enabled: sub.Enabled, Jobs: sub.JobCount}
|
||||
if sub.LastCheckedAt != nil {
|
||||
item.LastCheckedText = sub.LastCheckedAt.In(s.zone).Format("2006-01-02 15:04 MST")
|
||||
}
|
||||
view.Items = append(view.Items, item)
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func (s *server) pauseSubscription(w http.ResponseWriter, r *http.Request) {
|
||||
s.subscriptionMutation(w, r, func(ctx context.Context, id string) error {
|
||||
return s.jobs.SetSubscriptionEnabled(ctx, id, false)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) resumeSubscription(w http.ResponseWriter, r *http.Request) {
|
||||
s.subscriptionMutation(w, r, func(ctx context.Context, id string) error {
|
||||
return s.jobs.SetSubscriptionEnabled(ctx, id, true)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) deleteSubscription(w http.ResponseWriter, r *http.Request) {
|
||||
s.subscriptionMutation(w, r, func(ctx context.Context, id string) error {
|
||||
deleted, err := s.jobs.DeleteSubscription(ctx, id)
|
||||
if err == nil && !deleted {
|
||||
return store.ErrInvalidTransition
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) subscriptionMutation(w http.ResponseWriter, r *http.Request, change func(context.Context, string) error) {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
http.Error(w, "番組 ID が不正です", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := change(r.Context(), id); err != nil {
|
||||
if errors.Is(err, store.ErrInvalidTransition) {
|
||||
http.Error(w, "番組が見つかりません", http.StatusNotFound)
|
||||
} else {
|
||||
s.logger.Printf("登録番組の操作に失敗: %v", err)
|
||||
http.Error(w, "登録番組を変更できませんでした", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if r.Header.Get("HX-Request") != "true" {
|
||||
http.Redirect(w, r, "/subscriptions", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.subscriptionsFragment(w, r)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.kazu634.com/kazu634/tver/internal/store"
|
||||
"gitea.kazu634.com/kazu634/tver/internal/tver"
|
||||
)
|
||||
|
||||
func TestRegisterSubscriptionChoicesAndServerOwnedSeries(t *testing.T) {
|
||||
for _, download := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "without existing", true: "with existing"}[download], func(t *testing.T) {
|
||||
fetcher := &fakeFetcher{episodes: []tver.Episode{{ID: "ep1", SeriesID: "sr1", SeriesTitle: "本当の番組", Duration: 200}},
|
||||
seriesEpisodes: []tver.Episode{{ID: "ep1", SeriesID: "sr1", SeriesTitle: "本当の番組", Title: "公開中", Duration: 200}}}
|
||||
jobs := &fakeJobStore{}
|
||||
handler := newTestHandlerWithStore(fetcher, jobs, nil)
|
||||
page := getPage(handler, "/")
|
||||
if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), "番組を登録") || jobs.subscribedCalls != 1 || len(jobs.subscribedIDs) != 1 || jobs.subscribedIDs[0] != "sr1" {
|
||||
t.Fatalf("新着の登録状態: %d ids=%v body=%s", page.Code, jobs.subscribedIDs, page.Body.String())
|
||||
}
|
||||
choice := getPage(handler, "/subscriptions/choice?genre=drama&episode_id=ep1")
|
||||
if choice.Code != http.StatusOK || !strings.Contains(choice.Body.String(), "公開中の回もダウンロードする") || !strings.Contains(choice.Body.String(), ">登録</button>") {
|
||||
t.Fatalf("登録時の選択: %d %s", choice.Code, choice.Body.String())
|
||||
}
|
||||
values := url.Values{"genre": {"drama"}, "episode_id": {"ep1"}, "series_id": {"forged"}, "series_title": {"偽番組"}}
|
||||
if download {
|
||||
values.Set("download_existing", "1")
|
||||
}
|
||||
registered := postForm(handler, "/subscriptions/register", values)
|
||||
if registered.Code != http.StatusOK || !strings.Contains(registered.Body.String(), "登録済み") {
|
||||
t.Fatalf("登録応答: %d %s", registered.Code, registered.Body.String())
|
||||
}
|
||||
if jobs.registerCalls != 1 || jobs.registerDownload != download || len(jobs.registerEpisodes) != 1 || jobs.registerEpisodes[0].ID != "ep1" || jobs.registerEpisodes[0].SeriesID != "sr1" || jobs.subscriptions["sr1"].SeriesTitle != "本当の番組" {
|
||||
t.Fatalf("登録内容: calls=%d download=%t episodes=%+v subs=%+v", jobs.registerCalls, jobs.registerDownload, jobs.registerEpisodes, jobs.subscriptions)
|
||||
}
|
||||
if len(fetcher.seriesCalls) != 1 || fetcher.seriesCalls[0] != "sr1" {
|
||||
t.Fatalf("番組一覧取得: %v", fetcher.seriesCalls)
|
||||
}
|
||||
if !strings.Contains(getPage(handler, "/subscriptions").Body.String(), "最終確認: 2026-10-03 09:00 JST") {
|
||||
t.Fatal("登録直後の最終確認日時が表示されない")
|
||||
}
|
||||
fetcher.seriesErr = errors.New("API unavailable")
|
||||
duplicate := postForm(handler, "/subscriptions/register", values)
|
||||
if duplicate.Code != http.StatusOK || !strings.Contains(duplicate.Body.String(), "登録済み") || len(fetcher.seriesCalls) != 1 || jobs.registerCalls != 1 {
|
||||
t.Fatalf("重複登録が API を再取得: %d %s calls=%v register=%d", duplicate.Code, duplicate.Body.String(), fetcher.seriesCalls, jobs.registerCalls)
|
||||
}
|
||||
reloaded := getPage(handler, "/")
|
||||
if !strings.Contains(reloaded.Body.String(), "登録済み") || strings.Contains(reloaded.Body.String(), ">番組を登録</button>") {
|
||||
t.Fatalf("登録済みの表示: %s", reloaded.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterSubscriptionRejectsMissingCacheAndFetchFailure(t *testing.T) {
|
||||
fetcher := &fakeFetcher{episodes: []tver.Episode{{ID: "ep1", SeriesID: "sr1", SeriesTitle: "番組", Duration: 200}}}
|
||||
jobs := &fakeJobStore{}
|
||||
handler := newTestHandlerWithStore(fetcher, jobs, nil)
|
||||
values := url.Values{"genre": {"drama"}, "episode_id": {"ep1"}}
|
||||
if got := postForm(handler, "/subscriptions/register", values).Code; got != http.StatusNotFound {
|
||||
t.Fatalf("キャッシュ無しの登録: %d", got)
|
||||
}
|
||||
getPage(handler, "/")
|
||||
for _, path := range []string{"/subscriptions/register", "/subscriptions/sr1/pause", "/subscriptions/sr1/resume", "/subscriptions/sr1/delete"} {
|
||||
response := getPage(handler, path)
|
||||
if response.Code != http.StatusMethodNotAllowed || !strings.Contains(response.Header().Get("Allow"), "POST") {
|
||||
t.Errorf("GET での変更 %s: status=%d Allow=%q", path, response.Code, response.Header().Get("Allow"))
|
||||
}
|
||||
}
|
||||
for _, tt := range []struct {
|
||||
path string
|
||||
want int
|
||||
}{
|
||||
{"/subscriptions/choice?genre=news&episode_id=ep1", http.StatusBadRequest},
|
||||
{"/subscriptions/choice?genre=drama", http.StatusBadRequest},
|
||||
{"/subscriptions/choice?genre=drama&episode_id=missing", http.StatusNotFound},
|
||||
} {
|
||||
response := getPage(handler, tt.path)
|
||||
if response.Code != tt.want {
|
||||
t.Errorf("不正な選択 %s: got=%d want=%d", tt.path, response.Code, tt.want)
|
||||
}
|
||||
}
|
||||
for _, tt := range []struct {
|
||||
values url.Values
|
||||
want int
|
||||
}{
|
||||
{url.Values{"genre": {"news"}, "episode_id": {"ep1"}}, http.StatusBadRequest},
|
||||
{url.Values{"genre": {"drama"}}, http.StatusBadRequest},
|
||||
{url.Values{"genre": {"drama"}, "episode_id": {"missing"}}, http.StatusNotFound},
|
||||
} {
|
||||
response := postForm(handler, "/subscriptions/register", tt.values)
|
||||
if response.Code != tt.want {
|
||||
t.Errorf("不正な登録 %v: got=%d want=%d", tt.values, response.Code, tt.want)
|
||||
}
|
||||
}
|
||||
values.Set("download_existing", "bad")
|
||||
if got := postForm(handler, "/subscriptions/register", values).Code; got != http.StatusBadRequest {
|
||||
t.Fatalf("不正な選択: %d", got)
|
||||
}
|
||||
values.Del("download_existing")
|
||||
fetcher.seriesErr = errors.New("platform_token=secret& upstream failure")
|
||||
failed := postForm(handler, "/subscriptions/register", values)
|
||||
if failed.Code != http.StatusOK || !strings.Contains(failed.Body.String(), "公開中の回を取得できませんでした") || strings.Contains(failed.Body.String(), "secret") || jobs.registerCalls != 0 {
|
||||
t.Fatalf("取得失敗時に未登録: %d %s calls=%d", failed.Code, failed.Body.String(), jobs.registerCalls)
|
||||
}
|
||||
fetcher.seriesErr = nil
|
||||
fetcher.seriesEpisodes = []tver.Episode{{ID: "ep1", SeriesID: "sr1", SeriesTitle: "番組", Duration: 200}}
|
||||
jobs.registerErr = errors.New("secret database detail")
|
||||
failed = postForm(handler, "/subscriptions/register", values)
|
||||
if failed.Code != http.StatusOK || !strings.Contains(failed.Body.String(), "番組を登録できませんでした") || strings.Contains(failed.Body.String(), "secret") || len(jobs.subscriptions) != 0 {
|
||||
t.Fatalf("DB 失敗時に未登録: %d %s subscriptions=%v", failed.Code, failed.Body.String(), jobs.subscriptions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewerShowsInitiallySubscribedSeriesOncePerSeriesLookup(t *testing.T) {
|
||||
fetcher := &fakeFetcher{episodes: []tver.Episode{
|
||||
{ID: "ep1", SeriesID: "sr1", SeriesTitle: "番組", Duration: 200},
|
||||
{ID: "ep2", SeriesID: "sr1", SeriesTitle: "番組", Duration: 200},
|
||||
}}
|
||||
jobs := &fakeJobStore{subscriptions: map[string]store.Subscription{
|
||||
"sr1": {SeriesID: "sr1", SeriesTitle: "番組", Enabled: true},
|
||||
}}
|
||||
response := getPage(newTestHandlerWithStore(fetcher, jobs, nil), "/")
|
||||
body := response.Body.String()
|
||||
if response.Code != http.StatusOK || strings.Count(body, "登録済み") != 2 || strings.Contains(body, ">番組を登録</button>") || jobs.subscribedCalls != 1 || len(jobs.subscribedIDs) != 1 || jobs.subscribedIDs[0] != "sr1" {
|
||||
t.Fatalf("登録済み番組の初期表示: status=%d calls=%d ids=%v body=%s", response.Code, jobs.subscribedCalls, jobs.subscribedIDs, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionMutationsWithoutHtmxRedirect(t *testing.T) {
|
||||
fetcher := &fakeFetcher{
|
||||
episodes: []tver.Episode{{ID: "ep1", SeriesID: "sr1", SeriesTitle: "番組", Duration: 200}},
|
||||
seriesEpisodes: []tver.Episode{{ID: "ep1", SeriesID: "sr1", SeriesTitle: "番組", Duration: 200}},
|
||||
}
|
||||
jobs := &fakeJobStore{}
|
||||
handler := newTestHandlerWithStore(fetcher, jobs, nil)
|
||||
getPage(handler, "/")
|
||||
form := url.Values{"genre": {"drama"}, "episode_id": {"ep1"}}
|
||||
request := httptest.NewRequest(http.MethodPost, "/subscriptions/register", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/?genre=drama" {
|
||||
t.Fatalf("通常登録 POST: status=%d Location=%q", response.Code, response.Header().Get("Location"))
|
||||
}
|
||||
request = httptest.NewRequest(http.MethodPost, "/subscriptions/sr1/pause", nil)
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/subscriptions" || jobs.subscriptions["sr1"].Enabled {
|
||||
t.Fatalf("通常操作 POST: status=%d Location=%q subscription=%+v", response.Code, response.Header().Get("Location"), jobs.subscriptions["sr1"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionsPagePauseResumeDeleteAndEscaping(t *testing.T) {
|
||||
checked := time.Date(2026, 10, 4, 2, 30, 0, 0, time.UTC)
|
||||
jobs := &fakeJobStore{subscriptions: map[string]store.Subscription{
|
||||
"sr1": {SeriesID: "sr1", SeriesTitle: `<script>alert(1)</script>`, Enabled: true,
|
||||
CreatedAt: time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC), LastCheckedAt: &checked, JobCount: 3},
|
||||
"sr2": {SeriesID: "sr2", SeriesTitle: "別番組", Enabled: false, JobCount: 0},
|
||||
}}
|
||||
handler := newTestHandlerWithStore(&fakeFetcher{}, jobs, nil)
|
||||
page := getPage(handler, "/subscriptions")
|
||||
if page.Code != http.StatusOK {
|
||||
t.Fatalf("登録番組ページ: %d", page.Code)
|
||||
}
|
||||
body := page.Body.String()
|
||||
for _, want := range []string{"<script>", "登録日時: 2026-10-03 09:00 JST", "最終確認: 2026-10-04 11:30 JST", "最終確認: 未確認", "関連ジョブ: 3件", "有効", "一時停止", `hx-confirm="番組の登録を解除しますか?"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("登録番組の情報 %q がない", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, `<script>alert(1)</script>`) {
|
||||
t.Fatal("番組名がエスケープされていない")
|
||||
}
|
||||
paused := postForm(handler, "/subscriptions/sr1/pause", nil)
|
||||
if paused.Code != http.StatusOK || jobs.subscriptions["sr1"].Enabled || !strings.Contains(paused.Body.String(), `action="/subscriptions/sr1/resume"`) || strings.Contains(paused.Body.String(), "<html") {
|
||||
t.Fatalf("一時停止: %d %s", paused.Code, paused.Body.String())
|
||||
}
|
||||
resumed := postForm(handler, "/subscriptions/sr1/resume", nil)
|
||||
if resumed.Code != http.StatusOK || !jobs.subscriptions["sr1"].Enabled || !strings.Contains(resumed.Body.String(), `action="/subscriptions/sr1/pause"`) {
|
||||
t.Fatalf("再開: %d %s", resumed.Code, resumed.Body.String())
|
||||
}
|
||||
deleted := postForm(handler, "/subscriptions/sr1/delete", nil)
|
||||
if deleted.Code != http.StatusOK || len(jobs.subscriptions) != 1 || strings.Contains(deleted.Body.String(), "<script>") {
|
||||
t.Fatalf("解除: %d %s", deleted.Code, deleted.Body.String())
|
||||
}
|
||||
if got := postForm(handler, "/subscriptions/sr1/delete", nil).Code; got != http.StatusNotFound {
|
||||
t.Fatalf("存在しない番組の解除: %d", got)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@
|
||||
{{template "newer_page" .Newer}}
|
||||
{{else if .Queue}}
|
||||
{{template "queue_page" .Queue}}
|
||||
{{else if .Subscriptions}}
|
||||
{{template "subscriptions_page" .Subscriptions}}
|
||||
{{else}}
|
||||
<h1>{{.Title}}</h1>
|
||||
{{end}}
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
</div>
|
||||
</a>
|
||||
{{template "episode_action" .}}
|
||||
{{template "subscription_action" .SubscriptionAction}}
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
{{define "subscription_action"}}
|
||||
<div class="subscription-action">
|
||||
{{if .Subscribed}}
|
||||
<span role="status">登録済み</span>
|
||||
{{else if .Choosing}}
|
||||
{{if .ErrorText}}<p role="alert">{{.ErrorText}}</p>{{end}}
|
||||
<form action="/subscriptions/register" method="post" hx-post="/subscriptions/register" hx-target="closest .subscription-action" hx-swap="outerHTML">
|
||||
<input type="hidden" name="episode_id" value="{{.ID}}">
|
||||
<input type="hidden" name="genre" value="{{.Genre}}">
|
||||
<label><input type="checkbox" name="download_existing" value="1" {{if .DownloadExisting}}checked{{end}}>公開中の回もダウンロードする</label>
|
||||
<button type="submit">登録</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<button type="button" hx-get="/subscriptions/choice?genre={{.Genre}}&episode_id={{.ID}}" hx-target="closest .subscription-action" hx-swap="outerHTML">番組を登録</button>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "subscriptions_page"}}
|
||||
<h1>登録番組</h1>
|
||||
{{template "subscriptions_list" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "subscriptions_list"}}
|
||||
<div id="subscriptions-list">
|
||||
{{range .Items}}
|
||||
<article class="subscription-item">
|
||||
<h2>{{.Title}}</h2>
|
||||
<p>登録日時: {{.CreatedAtText}}/最終確認: {{.LastCheckedText}}/状態: {{if .Enabled}}有効{{else}}一時停止{{end}}/関連ジョブ: {{.Jobs}}件</p>
|
||||
{{if .Enabled}}
|
||||
<form action="/subscriptions/{{.ID}}/pause" method="post" hx-post="/subscriptions/{{.ID}}/pause" hx-target="#subscriptions-list" hx-swap="outerHTML"><button type="submit">一時停止</button></form>
|
||||
{{else}}
|
||||
<form action="/subscriptions/{{.ID}}/resume" method="post" hx-post="/subscriptions/{{.ID}}/resume" hx-target="#subscriptions-list" hx-swap="outerHTML"><button type="submit">再開</button></form>
|
||||
{{end}}
|
||||
<form action="/subscriptions/{{.ID}}/delete" method="post" hx-post="/subscriptions/{{.ID}}/delete" hx-target="#subscriptions-list" hx-swap="outerHTML" hx-confirm="番組の登録を解除しますか?"><button type="submit">解除</button></form>
|
||||
</article>
|
||||
{{else}}
|
||||
<p>登録番組はありません</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user