待ち行列への登録と状態表示を追加(新着からの登録、/queue、DATABASE_URL)
This commit is contained in:
@@ -121,6 +121,17 @@ func (s *Store) Retry(ctx context.Context, id int64) error {
|
||||
return transitionResult(tag.RowsAffected(), err)
|
||||
}
|
||||
|
||||
// RetryEpisode はエピソード ID で失敗・中断されたジョブを再投入する。
|
||||
func (s *Store) RetryEpisode(ctx context.Context, episodeID string) error {
|
||||
tag, err := s.pool.Exec(ctx, `UPDATE download_jobs SET status='queued', phase=NULL,
|
||||
downloaded_bytes=NULL, total_bytes=NULL, total_is_estimate=false, speed_bps=NULL,
|
||||
eta_seconds=NULL, fragment_index=NULL, fragment_count=NULL, format_id=NULL,
|
||||
error_kind=NULL, error_message=NULL, heartbeat_at=NULL, started_at=NULL,
|
||||
finished_at=NULL, updated_at=now()
|
||||
WHERE episode_id=$1 AND status IN ('failed','canceled')`, episodeID)
|
||||
return transitionResult(tag.RowsAffected(), err)
|
||||
}
|
||||
|
||||
// ClaimNext は最も古い未実行ジョブを同時 worker と重複せず取得する。
|
||||
func (s *Store) ClaimNext(ctx context.Context) (Job, error) {
|
||||
return scanJob(s.pool.QueryRow(ctx, `WITH next AS (
|
||||
@@ -162,6 +173,14 @@ func (s *Store) Cancel(ctx context.Context, id int64) error {
|
||||
return transitionResult(tag.RowsAffected(), err)
|
||||
}
|
||||
|
||||
// CancelQueued は worker 未着手のジョブだけを取り消す。
|
||||
func (s *Store) CancelQueued(ctx context.Context, id int64) error {
|
||||
tag, err := s.pool.Exec(ctx, `UPDATE download_jobs SET status='canceled', phase=NULL,
|
||||
error_kind='canceled', heartbeat_at=NULL, finished_at=now(), updated_at=now()
|
||||
WHERE id=$1 AND status='queued'`, id)
|
||||
return transitionResult(tag.RowsAffected(), err)
|
||||
}
|
||||
|
||||
// RequeueStale は cutoff より前に heartbeat が止まった実行中ジョブを戻す。
|
||||
func (s *Store) RequeueStale(ctx context.Context, cutoff time.Time) (int64, error) {
|
||||
tag, err := s.pool.Exec(ctx, `UPDATE download_jobs SET status='queued', phase=NULL,
|
||||
|
||||
@@ -407,6 +407,89 @@ func TestRejectedTransitions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryEpisode(t *testing.T) {
|
||||
s := New(testPool(t, true))
|
||||
ctx := context.Background()
|
||||
job, _, err := s.Enqueue(ctx, sampleEpisode("retry-by-episode"), "manual")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.RetryEpisode(ctx, job.EpisodeID); !errors.Is(err, ErrInvalidTransition) {
|
||||
t.Fatalf("待機中の再試行: %v", err)
|
||||
}
|
||||
if err := s.Cancel(ctx, job.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.RetryEpisode(ctx, job.EpisodeID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.GetJob(ctx, job.ID)
|
||||
if err != nil || got.Status != "queued" || got.ErrorKind != nil || got.FinishedAt != nil {
|
||||
t.Fatalf("エピソード ID による再試行: %+v %v", got, err)
|
||||
}
|
||||
claimed, err := s.ClaimNext(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
downloaded, total := int64(10), int64(20)
|
||||
format := "video-1"
|
||||
if err := s.UpdateProgress(ctx, claimed.ID, Progress{
|
||||
Phase: "video", DownloadedBytes: &downloaded, TotalBytes: &total,
|
||||
TotalIsEstimate: true, FormatID: &format,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Fail(ctx, claimed.ID, "network", "通信失敗"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.RetryEpisode(ctx, job.EpisodeID); err != nil {
|
||||
t.Fatalf("失敗からの再試行: %v", err)
|
||||
}
|
||||
got, err = s.GetJob(ctx, job.ID)
|
||||
if err != nil || got.Status != "queued" || got.Phase != nil || got.DownloadedBytes != nil || got.TotalBytes != nil || got.TotalIsEstimate || got.FormatID != nil || got.ErrorKind != nil || got.ErrorMessage != nil || got.HeartbeatAt != nil || got.StartedAt != nil || got.FinishedAt != nil || got.Attempts != 1 {
|
||||
t.Fatalf("失敗情報の消去: %+v %v", got, err)
|
||||
}
|
||||
if err := s.RetryEpisode(ctx, "unknown"); !errors.Is(err, ErrInvalidTransition) {
|
||||
t.Fatalf("存在しないエピソード: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelQueuedDoesNotCancelRunning(t *testing.T) {
|
||||
s := New(testPool(t, true))
|
||||
ctx := context.Background()
|
||||
queued, _, err := s.Enqueue(ctx, sampleEpisode("queue-cancel"), "manual")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.CancelQueued(ctx, queued.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canceled, err := s.GetJob(ctx, queued.ID)
|
||||
if err != nil || canceled.Status != "canceled" || canceled.ErrorKind == nil || *canceled.ErrorKind != "canceled" || canceled.FinishedAt == nil {
|
||||
t.Fatalf("待機中の取り消し: %+v %v", canceled, err)
|
||||
}
|
||||
if err := s.CancelQueued(ctx, queued.ID); !errors.Is(err, ErrInvalidTransition) {
|
||||
t.Fatalf("再取り消し: %v", err)
|
||||
}
|
||||
if err := s.CancelQueued(ctx, -1); !errors.Is(err, ErrInvalidTransition) {
|
||||
t.Fatalf("存在しないジョブの取り消し: %v", err)
|
||||
}
|
||||
if err := s.Retry(ctx, queued.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claimed, err := s.ClaimNext(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.CancelQueued(ctx, claimed.ID); !errors.Is(err, ErrInvalidTransition) {
|
||||
t.Fatalf("実行中の取り消し: %v", err)
|
||||
}
|
||||
job, err := s.GetJob(ctx, claimed.ID)
|
||||
if err != nil || job.Status != "running" {
|
||||
t.Fatalf("実行中の状態: %+v %v", job, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimNextConcurrent(t *testing.T) {
|
||||
s := New(testPool(t, true))
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gitea.kazu634.com/kazu634/tver/internal/store"
|
||||
)
|
||||
|
||||
type episodeActionView struct {
|
||||
ID, Genre, Status, StatusText string
|
||||
}
|
||||
|
||||
func statusText(status string) string {
|
||||
switch status {
|
||||
case "queued":
|
||||
return "待機中"
|
||||
case "running":
|
||||
return "ダウンロード中"
|
||||
case "succeeded":
|
||||
return "完了"
|
||||
case "failed":
|
||||
return "失敗"
|
||||
case "canceled":
|
||||
return "取り消し"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) enqueue(w http.ResponseWriter, r *http.Request) {
|
||||
genre, id, ok := episodeForm(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ep, found := s.cachedEpisode(genre, id)
|
||||
if !found {
|
||||
http.Error(w, "新着キャッシュにエピソードがありません。新着を再表示してください", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
job, _, err := s.jobs.Enqueue(r.Context(), storeEpisode(ep), "manual")
|
||||
if err != nil {
|
||||
s.logger.Printf("待ち行列への登録に失敗: %v", err)
|
||||
http.Error(w, "待ち行列に登録できませんでした", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.episodeActionResponse(w, r, episodeActionView{ID: id, Genre: genre, Status: job.Status, StatusText: statusText(job.Status)})
|
||||
}
|
||||
|
||||
func (s *server) retryEpisode(w http.ResponseWriter, r *http.Request) {
|
||||
genre, id, ok := episodeForm(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, found := s.cachedEpisode(genre, id); !found {
|
||||
http.Error(w, "新着キャッシュにエピソードがありません。新着を再表示してください", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := s.jobs.RetryEpisode(r.Context(), id); err != nil {
|
||||
if errors.Is(err, store.ErrInvalidTransition) {
|
||||
http.Error(w, "再試行できる状態ではありません", http.StatusConflict)
|
||||
} else {
|
||||
s.logger.Printf("ジョブの再試行に失敗: %v", err)
|
||||
http.Error(w, "再試行できませんでした", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
s.episodeActionResponse(w, r, episodeActionView{ID: id, Genre: genre, Status: "queued", StatusText: statusText("queued")})
|
||||
}
|
||||
|
||||
func episodeForm(w http.ResponseWriter, r *http.Request) (string, string, bool) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "フォームを読み取れませんでした", http.StatusBadRequest)
|
||||
return "", "", false
|
||||
}
|
||||
genre, id := r.Form.Get("genre"), r.Form.Get("episode_id")
|
||||
if (genre != "drama" && genre != "variety") || id == "" {
|
||||
http.Error(w, "ジャンルまたはエピソード ID が不正です", http.StatusBadRequest)
|
||||
return "", "", false
|
||||
}
|
||||
return genre, id, true
|
||||
}
|
||||
|
||||
func (s *server) episodeActionResponse(w http.ResponseWriter, r *http.Request, view episodeActionView) {
|
||||
if r.Header.Get("HX-Request") != "true" {
|
||||
http.Redirect(w, r, "/?genre="+view.Genre, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.render(w, "episode_action", view)
|
||||
}
|
||||
+23
-3
@@ -1,6 +1,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
@@ -8,6 +9,8 @@ import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.kazu634.com/kazu634/tver/internal/store"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html static/*
|
||||
@@ -16,10 +19,22 @@ var assets embed.FS
|
||||
type page struct {
|
||||
Title string
|
||||
Newer *newerView
|
||||
Queue *queueView
|
||||
}
|
||||
|
||||
type JobStore interface {
|
||||
Enqueue(context.Context, store.Episode, string) (store.Job, bool, error)
|
||||
EpisodeStatuses(context.Context, []string) (map[string]string, error)
|
||||
ListJobs(context.Context, string, int) ([]store.Job, error)
|
||||
GetJob(context.Context, int64) (store.Job, error)
|
||||
Retry(context.Context, int64) error
|
||||
RetryEpisode(context.Context, string) error
|
||||
CancelQueued(context.Context, int64) error
|
||||
}
|
||||
|
||||
type server struct {
|
||||
fetcher NewerFetcher
|
||||
jobs JobStore
|
||||
now func() time.Time
|
||||
logger *log.Logger
|
||||
zone *time.Location
|
||||
@@ -28,7 +43,7 @@ type server struct {
|
||||
cache map[string]cacheEntry
|
||||
}
|
||||
|
||||
func NewHandler(fetcher NewerFetcher, now func() time.Time, logger *log.Logger) http.Handler {
|
||||
func NewHandler(fetcher NewerFetcher, jobs JobStore, now func() time.Time, logger *log.Logger) http.Handler {
|
||||
tmpl := template.Must(template.ParseFS(assets, "templates/*.html"))
|
||||
staticFiles, err := fs.Sub(assets, "static")
|
||||
if err != nil {
|
||||
@@ -38,7 +53,7 @@ func NewHandler(fetcher NewerFetcher, now func() time.Time, logger *log.Logger)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
s := &server{fetcher: fetcher, now: now, logger: logger, zone: zone, tmpl: tmpl, cache: make(map[string]cacheEntry)}
|
||||
s := &server{fetcher: fetcher, jobs: jobs, now: now, logger: logger, zone: zone, tmpl: tmpl, cache: make(map[string]cacheEntry)}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticFiles)))
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -47,8 +62,13 @@ func NewHandler(fetcher NewerFetcher, now func() time.Time, logger *log.Logger)
|
||||
})
|
||||
mux.HandleFunc("GET /{$}", s.home)
|
||||
mux.HandleFunc("GET /newer", s.newerFragment)
|
||||
mux.HandleFunc("POST /episodes/enqueue", s.enqueue)
|
||||
mux.HandleFunc("POST /episodes/retry", s.retryEpisode)
|
||||
mux.HandleFunc("GET /queue", s.queuePage)
|
||||
mux.HandleFunc("GET /queue/list", s.queueFragment)
|
||||
mux.HandleFunc("POST /jobs/{id}/retry", s.retryJob)
|
||||
mux.HandleFunc("POST /jobs/{id}/cancel", s.cancelJob)
|
||||
for path, title := range map[string]string{
|
||||
"GET /queue": "待ち行列",
|
||||
"GET /subscriptions": "登録番組",
|
||||
"GET /library": "ライブラリ",
|
||||
} {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"gitea.kazu634.com/kazu634/tver/internal/store"
|
||||
"gitea.kazu634.com/kazu634/tver/internal/tver"
|
||||
)
|
||||
|
||||
@@ -34,6 +35,10 @@ type newerOptions struct {
|
||||
}
|
||||
|
||||
type episodeView struct {
|
||||
ID string
|
||||
Genre string
|
||||
Status string
|
||||
StatusText string
|
||||
URL string
|
||||
ThumbnailURL string
|
||||
SeriesTitle string
|
||||
@@ -50,6 +55,7 @@ type newerView struct {
|
||||
HidePreviews bool
|
||||
Episodes []episodeView
|
||||
Failed bool
|
||||
ErrorText string
|
||||
}
|
||||
|
||||
func parseNewerOptions(r *http.Request) (newerOptions, error) {
|
||||
@@ -123,14 +129,34 @@ func (s *server) newerView(ctx context.Context, options newerOptions) newerView
|
||||
if err != nil {
|
||||
s.logger.Printf("TVer の新着取得に失敗(genre=%s): %s", options.genre, platformTokenPattern.ReplaceAllString(err.Error(), "${1}[REDACTED]"))
|
||||
view.Failed = true
|
||||
view.ErrorText = "新着を取得できませんでした"
|
||||
return view
|
||||
}
|
||||
visible := make([]tver.Episode, 0, len(episodes))
|
||||
for _, episode := range episodes {
|
||||
if options.hidePreviews && episode.Duration <= previewMaxSeconds {
|
||||
continue
|
||||
}
|
||||
visible = append(visible, episode)
|
||||
}
|
||||
ids := make([]string, 0, len(visible))
|
||||
for _, episode := range visible {
|
||||
ids = append(ids, episode.ID)
|
||||
}
|
||||
statuses, err := s.jobs.EpisodeStatuses(ctx, ids)
|
||||
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,
|
||||
Genre: options.genre,
|
||||
Status: statuses[episode.ID],
|
||||
StatusText: statusText(statuses[episode.ID]),
|
||||
URL: "https://tver.jp/episodes/" + url.PathEscape(episode.ID),
|
||||
ThumbnailURL: "https://statics.tver.jp" + episode.ThumbnailPath,
|
||||
SeriesTitle: episode.SeriesTitle,
|
||||
@@ -145,6 +171,32 @@ func (s *server) newerView(ctx context.Context, options newerOptions) newerView
|
||||
return view
|
||||
}
|
||||
|
||||
func (s *server) cachedEpisode(genre, id string) (tver.Episode, bool) {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
entry, ok := s.cache[genre]
|
||||
if !ok || !s.now().Before(entry.expiresAt) {
|
||||
return tver.Episode{}, false
|
||||
}
|
||||
for _, ep := range entry.episodes {
|
||||
if ep.ID == id {
|
||||
return ep, true
|
||||
}
|
||||
}
|
||||
return tver.Episode{}, false
|
||||
}
|
||||
|
||||
func storeEpisode(ep tver.Episode) store.Episode {
|
||||
var endAt *time.Time
|
||||
if ep.EndAt > 0 {
|
||||
end := time.Unix(ep.EndAt, 0)
|
||||
endAt = &end
|
||||
}
|
||||
return store.Episode{ID: ep.ID, SeriesID: ep.SeriesID, SeriesTitle: ep.SeriesTitle,
|
||||
Title: ep.Title, BroadcasterName: ep.BroadcasterName, DurationSeconds: int(ep.Duration),
|
||||
EndAt: endAt, ThumbnailPath: ep.ThumbnailPath}
|
||||
}
|
||||
|
||||
func durationMinutes(seconds int64) int64 {
|
||||
minutes := seconds / 60
|
||||
if seconds%60 != 0 {
|
||||
|
||||
@@ -31,10 +31,14 @@ func (f *fakeFetcher) FetchNewer(_ context.Context, genre string) ([]tver.Episod
|
||||
}
|
||||
|
||||
func newTestHandler(fetcher *fakeFetcher, now func() time.Time) http.Handler {
|
||||
return newTestHandlerWithStore(fetcher, &fakeJobStore{}, now)
|
||||
}
|
||||
|
||||
func newTestHandlerWithStore(fetcher *fakeFetcher, jobs *fakeJobStore, now func() time.Time) http.Handler {
|
||||
if now == nil {
|
||||
now = func() time.Time { return time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC) }
|
||||
}
|
||||
return NewHandler(fetcher, now, log.New(io.Discard, "", 0))
|
||||
return NewHandler(fetcher, jobs, now, log.New(io.Discard, "", 0))
|
||||
}
|
||||
|
||||
func getPage(handler http.Handler, path string) *httptest.ResponseRecorder {
|
||||
@@ -226,7 +230,7 @@ func TestNewerPageEscapesUntrustedEpisodeFields(t *testing.T) {
|
||||
func TestNewerFailureShowsFriendlyMessageAndRedactsToken(t *testing.T) {
|
||||
fetcher := &fakeFetcher{err: errors.New("GET https://platform-api.tver.jp/?platform_token=secret-123&x=1: upstream unavailable")}
|
||||
var logs bytes.Buffer
|
||||
handler := NewHandler(fetcher, time.Now, log.New(&logs, "", 0))
|
||||
handler := NewHandler(fetcher, &fakeJobStore{}, time.Now, log.New(&logs, "", 0))
|
||||
response := getPage(handler, "/")
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "新着を取得できませんでした") {
|
||||
t.Errorf("error page: status=%d body=%s", response.Code, response.Body.String())
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"gitea.kazu634.com/kazu634/tver/internal/store"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const queueListLimit = 50
|
||||
|
||||
type queueJobView struct {
|
||||
store.Job
|
||||
SourceText, CreatedAtText, PhaseText string
|
||||
PercentText, SpeedText, ETAText string
|
||||
FragmentText, ErrorKindText string
|
||||
}
|
||||
|
||||
type queueView struct {
|
||||
Running, Queued, Failed, Canceled, Succeeded []queueJobView
|
||||
Poll bool
|
||||
}
|
||||
|
||||
func (s *server) queuePage(w http.ResponseWriter, r *http.Request) {
|
||||
view, err := s.loadQueue(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Printf("待ち行列の取得に失敗: %v", err)
|
||||
http.Error(w, "待ち行列を取得できませんでした", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.render(w, "layout", page{Title: "待ち行列", Queue: &view})
|
||||
}
|
||||
|
||||
func (s *server) queueFragment(w http.ResponseWriter, r *http.Request) {
|
||||
view, err := s.loadQueue(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Printf("待ち行列の取得に失敗: %v", err)
|
||||
http.Error(w, "待ち行列を取得できませんでした", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.render(w, "queue_list", view)
|
||||
}
|
||||
|
||||
func (s *server) loadQueue(ctx context.Context) (queueView, error) {
|
||||
var view queueView
|
||||
for _, item := range []struct {
|
||||
status string
|
||||
assign func([]queueJobView)
|
||||
}{
|
||||
{"running", func(j []queueJobView) { view.Running = j }},
|
||||
{"queued", func(j []queueJobView) { view.Queued = j }},
|
||||
{"failed", func(j []queueJobView) { view.Failed = j }},
|
||||
{"canceled", func(j []queueJobView) { view.Canceled = j }},
|
||||
{"succeeded", func(j []queueJobView) { view.Succeeded = j }},
|
||||
} {
|
||||
jobs, err := s.jobs.ListJobs(ctx, item.status, queueListLimit)
|
||||
if err != nil {
|
||||
return queueView{}, err
|
||||
}
|
||||
views := make([]queueJobView, 0, len(jobs))
|
||||
for _, job := range jobs {
|
||||
views = append(views, s.queueJob(job))
|
||||
}
|
||||
item.assign(views)
|
||||
}
|
||||
view.Poll = len(view.Running) > 0 || len(view.Queued) > 0
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func (s *server) queueJob(job store.Job) queueJobView {
|
||||
view := queueJobView{Job: job, CreatedAtText: job.CreatedAt.In(s.zone).Format("2006-01-02 15:04 MST")}
|
||||
if job.Source == "subscription" {
|
||||
view.SourceText = "自動"
|
||||
} else {
|
||||
view.SourceText = "手動"
|
||||
}
|
||||
if job.Phase != nil {
|
||||
switch *job.Phase {
|
||||
case "video":
|
||||
view.PhaseText = "映像"
|
||||
case "audio":
|
||||
view.PhaseText = "音声"
|
||||
case "merging":
|
||||
view.PhaseText = "結合"
|
||||
case "moving":
|
||||
view.PhaseText = "移動"
|
||||
}
|
||||
}
|
||||
if job.DownloadedBytes != nil && job.TotalBytes != nil && *job.TotalBytes > 0 {
|
||||
percent := 100 * float64(*job.DownloadedBytes) / float64(*job.TotalBytes)
|
||||
percent = max(0, min(percent, 100))
|
||||
if job.TotalIsEstimate {
|
||||
view.PercentText = fmt.Sprintf("約%.0f%%", percent)
|
||||
} else {
|
||||
view.PercentText = fmt.Sprintf("%.0f%%", percent)
|
||||
}
|
||||
}
|
||||
if job.SpeedBPS != nil && *job.SpeedBPS > 0 {
|
||||
view.SpeedText = formatBytes(*job.SpeedBPS) + "/秒"
|
||||
}
|
||||
if job.ETASeconds != nil && *job.ETASeconds >= 0 {
|
||||
seconds := int64(*job.ETASeconds)
|
||||
view.ETAText = fmt.Sprintf("残り%d分%d秒", seconds/60, seconds%60)
|
||||
}
|
||||
if job.FragmentIndex != nil && job.FragmentCount != nil && *job.FragmentCount > 0 {
|
||||
view.FragmentText = fmt.Sprintf("断片 %d/%d", *job.FragmentIndex, *job.FragmentCount)
|
||||
}
|
||||
if job.ErrorKind != nil {
|
||||
view.ErrorKindText = errorKindText(*job.ErrorKind)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func formatBytes(n float64) string {
|
||||
for _, unit := range []string{"B", "KiB", "MiB", "GiB"} {
|
||||
if n < 1024 || unit == "GiB" {
|
||||
return fmt.Sprintf("%.1f %s", n, unit)
|
||||
}
|
||||
n /= 1024
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func errorKindText(kind string) string {
|
||||
switch kind {
|
||||
case "drm":
|
||||
return "DRM のため取得不可"
|
||||
case "geo":
|
||||
return "地域制限"
|
||||
case "network":
|
||||
return "ネットワーク"
|
||||
case "unavailable":
|
||||
return "配信終了・存在しない"
|
||||
case "extract":
|
||||
return "抽出失敗"
|
||||
case "invalid_output":
|
||||
return "出力形式の不正"
|
||||
default:
|
||||
return "不明"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) retryJob(w http.ResponseWriter, r *http.Request) {
|
||||
s.queueMutation(w, r, s.jobs.Retry, false)
|
||||
}
|
||||
|
||||
func (s *server) cancelJob(w http.ResponseWriter, r *http.Request) {
|
||||
s.queueMutation(w, r, s.jobs.CancelQueued, true)
|
||||
}
|
||||
|
||||
func (s *server) queueMutation(w http.ResponseWriter, r *http.Request, change func(context.Context, int64) error, queuedOnly bool) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
http.Error(w, "ジョブ ID が不正です", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if queuedOnly {
|
||||
job, err := s.jobs.GetJob(r.Context(), id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "ジョブが見つかりません", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Printf("ジョブ状態の取得に失敗: %v", err)
|
||||
http.Error(w, "操作できませんでした", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if job.Status != "queued" {
|
||||
http.Error(w, "待機中のジョブだけ取り消せます", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := change(r.Context(), id); err != nil {
|
||||
if errors.Is(err, store.ErrInvalidTransition) {
|
||||
http.Error(w, "操作できる状態ではありません", http.StatusConflict)
|
||||
} else {
|
||||
s.logger.Printf("待ち行列の操作に失敗: %v", err)
|
||||
http.Error(w, "操作できませんでした", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if r.Header.Get("HX-Request") != "true" {
|
||||
http.Redirect(w, r, "/queue", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.queueFragment(w, r)
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.kazu634.com/kazu634/tver/internal/store"
|
||||
"gitea.kazu634.com/kazu634/tver/internal/tver"
|
||||
)
|
||||
|
||||
func postForm(handler http.Handler, path string, values url.Values) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.Header.Set("HX-Request", "true")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestEnqueueUsesCachedEpisodeAndDuplicateStatus(t *testing.T) {
|
||||
fetcher := &fakeFetcher{episodes: []tver.Episode{{ID: "ep1", SeriesID: "series1", SeriesTitle: "正しい番組", Title: "正しい回", BroadcasterName: "正しい局", Duration: 1234, EndAt: 1791061200, ThumbnailPath: "/correct.jpg"}}}
|
||||
jobs := &fakeJobStore{}
|
||||
handler := newTestHandlerWithStore(fetcher, jobs, nil)
|
||||
getPage(handler, "/")
|
||||
if jobs.statusCalls != 1 || len(jobs.statusIDs) != 1 || jobs.statusIDs[0] != "ep1" {
|
||||
t.Fatalf("状態の一括取得: calls=%d ids=%v", jobs.statusCalls, jobs.statusIDs)
|
||||
}
|
||||
values := url.Values{"genre": {"drama"}, "episode_id": {"ep1"}, "series_title": {"偽番組"}, "title": {"偽回"}}
|
||||
response := postForm(handler, "/episodes/enqueue", values)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "待機中") || !strings.Contains(response.Body.String(), `class="episode-action"`) || strings.Contains(response.Body.String(), "<html") || strings.Contains(response.Body.String(), "episode-card") {
|
||||
t.Fatalf("登録応答: %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
if jobs.lastEpisode.SeriesTitle != "正しい番組" || jobs.lastEpisode.Title != "正しい回" || jobs.lastEpisode.BroadcasterName != "正しい局" || jobs.lastEpisode.DurationSeconds != 1234 || jobs.lastEpisode.ThumbnailPath != "/correct.jpg" || jobs.lastEpisode.EndAt == nil {
|
||||
t.Fatalf("キャッシュ外の情報を使用: %+v", jobs.lastEpisode)
|
||||
}
|
||||
if len(fetcher.calls) != 1 {
|
||||
t.Fatalf("POST が TVer に再問い合わせ: %v", fetcher.calls)
|
||||
}
|
||||
duplicate := postForm(handler, "/episodes/enqueue", values)
|
||||
if duplicate.Code != http.StatusOK || !strings.Contains(duplicate.Body.String(), "待機中") || len(jobs.jobs) != 1 {
|
||||
t.Fatalf("二重登録: %d %s jobs=%d", duplicate.Code, duplicate.Body.String(), len(jobs.jobs))
|
||||
}
|
||||
page := getPage(handler, "/")
|
||||
if !strings.Contains(page.Body.String(), "待機中") || strings.Contains(page.Body.String(), ">ダウンロード</button>") {
|
||||
t.Fatalf("登録後の一覧: %s", page.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnqueueRejectsInvalidOrExpiredCache(t *testing.T) {
|
||||
now := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)
|
||||
fetcher := &fakeFetcher{episodes: []tver.Episode{{ID: "ep1", Duration: 200}}}
|
||||
jobs := &fakeJobStore{}
|
||||
handler := newTestHandlerWithStore(fetcher, jobs, func() time.Time { return now })
|
||||
valid := url.Values{"genre": {"drama"}, "episode_id": {"ep1"}}
|
||||
if got := postForm(handler, "/episodes/enqueue", valid).Code; got != http.StatusNotFound {
|
||||
t.Fatalf("未取得キャッシュ: %d", got)
|
||||
}
|
||||
getPage(handler, "/")
|
||||
for _, values := range []url.Values{
|
||||
{"genre": {"news"}, "episode_id": {"ep1"}},
|
||||
{"genre": {"drama"}},
|
||||
} {
|
||||
if got := postForm(handler, "/episodes/enqueue", values).Code; got != http.StatusBadRequest {
|
||||
t.Errorf("不正なフォーム: %d", got)
|
||||
}
|
||||
}
|
||||
if got := postForm(handler, "/episodes/enqueue", url.Values{"genre": {"drama"}, "episode_id": {"unknown"}}).Code; got != http.StatusNotFound {
|
||||
t.Fatalf("同じジャンルの未収録 ID: %d", got)
|
||||
}
|
||||
if got := postForm(handler, "/episodes/enqueue", url.Values{"genre": {"variety"}, "episode_id": {"ep1"}}).Code; got != http.StatusNotFound {
|
||||
t.Fatalf("別ジャンルのキャッシュ: %d", got)
|
||||
}
|
||||
now = now.Add(newerCacheTTL)
|
||||
if got := postForm(handler, "/episodes/enqueue", valid).Code; got != http.StatusNotFound {
|
||||
t.Fatalf("期限切れキャッシュ: %d", got)
|
||||
}
|
||||
if jobs.enqueueCalls != 0 || len(fetcher.calls) != 1 {
|
||||
t.Fatalf("無効な入力で登録・再取得: enqueue=%d fetch=%v", jobs.enqueueCalls, fetcher.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnqueueUsesSelectedGenreCacheForSameEpisodeID(t *testing.T) {
|
||||
fetcher := &fakeFetcher{episodesByGenre: map[string][]tver.Episode{
|
||||
"drama": {{ID: "shared", SeriesTitle: "ドラマの番組", Duration: 200}},
|
||||
"variety": {{ID: "shared", SeriesTitle: "バラエティの番組", Duration: 200}},
|
||||
}}
|
||||
jobs := &fakeJobStore{}
|
||||
handler := newTestHandlerWithStore(fetcher, jobs, nil)
|
||||
getPage(handler, "/")
|
||||
getPage(handler, "/?genre=variety")
|
||||
response := postForm(handler, "/episodes/enqueue", url.Values{
|
||||
"genre": {"variety"}, "episode_id": {"shared"}, "series_title": {"偽タイトル"},
|
||||
})
|
||||
if response.Code != http.StatusOK || jobs.lastEpisode.SeriesTitle != "バラエティの番組" || jobs.enqueueCalls != 1 || !slices.Equal(fetcher.calls, []string{"drama", "variety"}) {
|
||||
t.Fatalf("選択ジャンルのキャッシュ: status=%d episode=%+v calls=%v", response.Code, jobs.lastEpisode, fetcher.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewerStatusesAndRetry(t *testing.T) {
|
||||
fetcher := &fakeFetcher{episodes: []tver.Episode{{ID: "failed", Duration: 200}, {ID: "canceled", Duration: 200}, {ID: "running", Duration: 200}, {ID: "done", Duration: 200}}}
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{
|
||||
"failed": {ID: 1, Status: "failed"}, "canceled": {ID: 2, Status: "canceled"},
|
||||
"running": {ID: 3, Status: "running"}, "done": {ID: 4, Status: "succeeded"},
|
||||
}}
|
||||
handler := newTestHandlerWithStore(fetcher, jobs, nil)
|
||||
body := getPage(handler, "/").Body.String()
|
||||
for _, want := range []string{"失敗", "取り消し", "ダウンロード中", "完了"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("状態表示がない: %s", want)
|
||||
}
|
||||
}
|
||||
if got := strings.Count(body, ">再試行</button>"); got != 2 {
|
||||
t.Fatalf("再試行ボタン数: %d", got)
|
||||
}
|
||||
response := postForm(handler, "/episodes/retry", url.Values{"genre": {"drama"}, "episode_id": {"failed"}})
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "待機中") || jobs.jobs["failed"].Status != "queued" {
|
||||
t.Fatalf("新着から再試行: %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
if got := postForm(handler, "/episodes/retry", url.Values{"genre": {"drama"}, "episode_id": {"running"}}).Code; got != http.StatusConflict {
|
||||
t.Fatalf("実行中の再試行: %d", got)
|
||||
}
|
||||
if got := postForm(handler, "/episodes/retry", url.Values{"genre": {"drama"}, "episode_id": {"missing"}}).Code; got != http.StatusNotFound {
|
||||
t.Fatalf("キャッシュにない ID の再試行: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueGroupsProgressAndPolling(t *testing.T) {
|
||||
phase, errorKind, errorMessage := "video", "drm", `<script>alert(1)</script>`
|
||||
downloaded, total := int64(25), int64(100)
|
||||
speed, eta := float64(2048), float64(63)
|
||||
index, count := int32(2), int32(10)
|
||||
created := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{
|
||||
"running": {ID: 1, Status: "running", Source: "subscription", SeriesTitle: "番組A", Title: "第1話", BroadcasterName: "局A", CreatedAt: created, Phase: &phase, DownloadedBytes: &downloaded, TotalBytes: &total, TotalIsEstimate: true, SpeedBPS: &speed, ETASeconds: &eta, FragmentIndex: &index, FragmentCount: &count},
|
||||
"queued": {ID: 2, Status: "queued", Source: "manual", SeriesTitle: "番組B", CreatedAt: created},
|
||||
"failed": {ID: 3, Status: "failed", ErrorKind: &errorKind, ErrorMessage: &errorMessage, CreatedAt: created},
|
||||
"canceled": {ID: 4, Status: "canceled", CreatedAt: created},
|
||||
"done": {ID: 5, Status: "succeeded", CreatedAt: created},
|
||||
}}
|
||||
handler := newTestHandlerWithStore(&fakeFetcher{}, jobs, nil)
|
||||
response := getPage(handler, "/queue")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("待ち行列: %d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
for _, want := range []string{"ダウンロード中", "待機中", "失敗", "取り消し", "完了(直近50件)", "番組A", "第1話", "局A", "自動", "手動", "2026-10-03 09:00 JST", "映像", "約25%", "2.0 KiB/秒", "残り1分3秒", "断片 2/10", "DRM のため取得不可", "<details>", "<script>", `hx-get="/queue/list"`, `hx-trigger="every 5s"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("待ち行列に %q がない", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, errorMessage) || strings.Contains(body, `action="/jobs/1/cancel"`) || strings.Contains(body, "全体") {
|
||||
t.Errorf("不正な詳細・操作・全体進捗: %s", body)
|
||||
}
|
||||
fragment := getPage(handler, "/queue/list")
|
||||
if fragment.Code != http.StatusOK || strings.Contains(fragment.Body.String(), "<html") || !strings.Contains(fragment.Body.String(), `id="queue-list"`) {
|
||||
t.Errorf("待ち行列断片: %d %s", fragment.Code, fragment.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueJobsAppearInCorrectSectionsWithLimit(t *testing.T) {
|
||||
statuses := []string{"running", "queued", "failed", "canceled", "succeeded"}
|
||||
labels := []string{"ダウンロード中", "待機中", "失敗", "取り消し", "完了"}
|
||||
jobs := &fakeJobStore{jobs: make(map[string]store.Job)}
|
||||
for i, status := range statuses {
|
||||
jobs.jobs[status] = store.Job{ID: int64(i + 1), Status: status, SeriesTitle: "番組-" + status}
|
||||
}
|
||||
response := getPage(newTestHandlerWithStore(&fakeFetcher{}, jobs, nil), "/queue")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("待ち行列: %d", response.Code)
|
||||
}
|
||||
if !slices.Equal(jobs.listStatuses, statuses) {
|
||||
t.Errorf("状態別取得: %v", jobs.listStatuses)
|
||||
}
|
||||
if !slices.Equal(jobs.listLimits, []int{50, 50, 50, 50, 50}) {
|
||||
t.Errorf("状態ごとの取得上限: %v", jobs.listLimits)
|
||||
}
|
||||
for i, label := range labels {
|
||||
section := regexp.MustCompile(`(?s)<section aria-label="` + label + `">(.*?)</section>`).FindStringSubmatch(response.Body.String())
|
||||
if len(section) != 2 || !strings.Contains(section[1], "番組-"+statuses[i]) {
|
||||
t.Errorf("%s に %s がない: %v", label, statuses[i], section)
|
||||
continue
|
||||
}
|
||||
for j, other := range statuses {
|
||||
if j != i && strings.Contains(section[1], "番組-"+other) {
|
||||
t.Errorf("%s に %s が混入", label, other)
|
||||
}
|
||||
}
|
||||
cancelAction := `action="/jobs/` + string(rune('1'+i)) + `/cancel"`
|
||||
retryAction := `action="/jobs/` + string(rune('1'+i)) + `/retry"`
|
||||
switch statuses[i] {
|
||||
case "queued":
|
||||
if !strings.Contains(section[1], cancelAction) || strings.Contains(section[1], retryAction) {
|
||||
t.Errorf("%s の操作が不正: %s", statuses[i], section[1])
|
||||
}
|
||||
case "failed", "canceled":
|
||||
if !strings.Contains(section[1], retryAction) || strings.Contains(section[1], cancelAction) {
|
||||
t.Errorf("%s の操作が不正: %s", statuses[i], section[1])
|
||||
}
|
||||
default:
|
||||
if strings.Contains(section[1], "/cancel") || strings.Contains(section[1], "/retry") {
|
||||
t.Errorf("%s に操作がある: %s", statuses[i], section[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueEscapesEpisodeFields(t *testing.T) {
|
||||
attack := `<script>alert(1)</script>`
|
||||
message := `<img src=x onerror=alert(1)>`
|
||||
kind := "extract"
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{
|
||||
"bad": {ID: 1, Status: "failed", SeriesTitle: attack, Title: attack,
|
||||
BroadcasterName: attack, ErrorKind: &kind, ErrorMessage: &message},
|
||||
}}
|
||||
response := getPage(newTestHandlerWithStore(&fakeFetcher{}, jobs, nil), "/queue")
|
||||
body := response.Body.String()
|
||||
if response.Code != http.StatusOK || strings.Contains(body, attack) || strings.Contains(body, message) || strings.Count(body, "<script>") != 3 || !strings.Contains(body, "<img src=x onerror=alert(1)>") {
|
||||
t.Fatalf("待ち行列の HTML エスケープ: %d %s", response.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueCancelRetryAndStopPolling(t *testing.T) {
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep": {ID: 1, Status: "queued"}}}
|
||||
handler := newTestHandlerWithStore(&fakeFetcher{}, jobs, nil)
|
||||
if !strings.Contains(getPage(handler, "/queue").Body.String(), `hx-get="/queue/list"`) {
|
||||
t.Fatal("待機中なのに更新がない")
|
||||
}
|
||||
canceled := postForm(handler, "/jobs/1/cancel", nil)
|
||||
if canceled.Code != http.StatusOK || !strings.Contains(canceled.Body.String(), `action="/jobs/1/retry"`) || strings.Contains(canceled.Body.String(), `hx-get="/queue/list"`) {
|
||||
t.Fatalf("取り消し後: %d %s", canceled.Code, canceled.Body.String())
|
||||
}
|
||||
retried := postForm(handler, "/jobs/1/retry", nil)
|
||||
if retried.Code != http.StatusOK || !strings.Contains(retried.Body.String(), `action="/jobs/1/cancel"`) || !strings.Contains(retried.Body.String(), `hx-get="/queue/list"`) {
|
||||
t.Fatalf("再試行後: %d %s", retried.Code, retried.Body.String())
|
||||
}
|
||||
if got := postForm(handler, "/jobs/1/retry", nil).Code; got != http.StatusConflict {
|
||||
t.Fatalf("待機中の再試行: %d", got)
|
||||
}
|
||||
if got := postForm(handler, "/jobs/bad/cancel", nil).Code; got != http.StatusBadRequest {
|
||||
t.Fatalf("不正な ID: %d", got)
|
||||
}
|
||||
jobs.jobs["ep"] = store.Job{ID: 1, Status: "running"}
|
||||
if got := postForm(handler, "/jobs/1/cancel", nil).Code; got != http.StatusConflict {
|
||||
t.Fatalf("実行中の取り消し: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateChangingGETIsRejected(t *testing.T) {
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep": {ID: 1, Status: "queued"}}}
|
||||
handler := newTestHandlerWithStore(&fakeFetcher{episodes: []tver.Episode{{ID: "ep", Duration: 200}}}, jobs, nil)
|
||||
getPage(handler, "/")
|
||||
for _, path := range []string{
|
||||
"/episodes/enqueue?episode_id=ep&genre=drama",
|
||||
"/episodes/retry?episode_id=ep&genre=drama",
|
||||
"/jobs/1/cancel", "/jobs/1/retry",
|
||||
} {
|
||||
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"))
|
||||
}
|
||||
}
|
||||
if jobs.jobs["ep"].Status != "queued" || jobs.enqueueCalls != 0 {
|
||||
t.Fatalf("GET が状態を変更した: jobs=%+v enqueue=%d", jobs.jobs, jobs.enqueueCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateChangingPOSTWithoutHtmxRedirects(t *testing.T) {
|
||||
jobs := &fakeJobStore{}
|
||||
handler := newTestHandlerWithStore(&fakeFetcher{episodes: []tver.Episode{{ID: "ep", Duration: 200}}}, jobs, nil)
|
||||
getPage(handler, "/")
|
||||
form := url.Values{"episode_id": {"ep"}, "genre": {"drama"}}
|
||||
request := httptest.NewRequest(http.MethodPost, "/episodes/enqueue", 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" || jobs.jobs["ep"].Status != "queued" {
|
||||
t.Fatalf("通常 POST 後の遷移: status=%d Location=%q jobs=%+v", response.Code, response.Header().Get("Location"), jobs.jobs)
|
||||
}
|
||||
request = httptest.NewRequest(http.MethodPost, "/jobs/1/cancel", nil)
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/queue" || jobs.jobs["ep"].Status != "canceled" {
|
||||
t.Fatalf("待ち行列操作後の遷移: status=%d Location=%q jobs=%+v", response.Code, response.Header().Get("Location"), jobs.jobs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewerStoreFailureDoesNotOfferDownload(t *testing.T) {
|
||||
jobs := &fakeJobStore{err: errors.New("db unavailable")}
|
||||
handler := newTestHandlerWithStore(&fakeFetcher{episodes: []tver.Episode{{ID: "ep", Duration: 200}}}, jobs, nil)
|
||||
response := getPage(handler, "/")
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "状態を取得できませんでした") || strings.Contains(response.Body.String(), ">ダウンロード</button>") {
|
||||
t.Errorf("状態取得失敗: %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnqueueStoreFailureShowsShortError(t *testing.T) {
|
||||
jobs := &fakeJobStore{}
|
||||
handler := newTestHandlerWithStore(&fakeFetcher{episodes: []tver.Episode{{ID: "ep", Duration: 200}}}, jobs, nil)
|
||||
getPage(handler, "/")
|
||||
jobs.err = errors.New("secret database detail")
|
||||
response := postForm(handler, "/episodes/enqueue", url.Values{"genre": {"drama"}, "episode_id": {"ep"}})
|
||||
if response.Code != http.StatusInternalServerError || !strings.Contains(response.Body.String(), "待ち行列に登録できませんでした") || strings.Contains(response.Body.String(), "secret") {
|
||||
t.Errorf("DB エラーの表示: %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueProgressAndFailureLabels(t *testing.T) {
|
||||
s := &server{zone: time.FixedZone("JST", 9*60*60)}
|
||||
for _, tt := range []struct{ kind, label string }{
|
||||
{"drm", "DRM のため取得不可"}, {"geo", "地域制限"},
|
||||
{"network", "ネットワーク"}, {"unavailable", "配信終了・存在しない"},
|
||||
{"extract", "抽出失敗"}, {"invalid_output", "出力形式の不正"},
|
||||
{"unknown", "不明"},
|
||||
} {
|
||||
if got := errorKindText(tt.kind); got != tt.label {
|
||||
t.Errorf("%s: %q", tt.kind, got)
|
||||
}
|
||||
}
|
||||
for _, tt := range []struct{ phase, label string }{
|
||||
{"video", "映像"}, {"audio", "音声"}, {"merging", "結合"}, {"moving", "移動"},
|
||||
} {
|
||||
if got := s.queueJob(store.Job{Phase: &tt.phase}).PhaseText; got != tt.label {
|
||||
t.Errorf("%s: %q", tt.phase, got)
|
||||
}
|
||||
}
|
||||
downloaded, total := int64(50), int64(100)
|
||||
job := store.Job{DownloadedBytes: &downloaded, TotalBytes: &total}
|
||||
if got := s.queueJob(job).PercentText; got != "50%" {
|
||||
t.Errorf("確定した段階の割合: %q", got)
|
||||
}
|
||||
job.TotalIsEstimate = true
|
||||
if got := s.queueJob(job).PercentText; got != "約50%" {
|
||||
t.Errorf("推定した段階の割合: %q", got)
|
||||
}
|
||||
total = 0
|
||||
if got := s.queueJob(job).PercentText; got != "" {
|
||||
t.Errorf("総量ゼロでの割合: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -13,3 +13,10 @@ main { max-width: 72rem; margin: 2rem auto; padding: 0 2rem; }
|
||||
.episode-copy { padding: .8rem 1rem; }
|
||||
.episode-copy h2 { font-size: 1.1rem; margin: 0 0 .4rem; }
|
||||
.episode-copy p { margin: .3rem 0; }
|
||||
.episode-action { padding: 0 .8rem .8rem; }
|
||||
.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; }
|
||||
.queue-job h3 { margin: 0 0 .4rem; }
|
||||
.queue-job p { margin: .4rem 0; }
|
||||
.queue-job pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"gitea.kazu634.com/kazu634/tver/internal/store"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type fakeJobStore struct {
|
||||
jobs map[string]store.Job
|
||||
nextID int64
|
||||
lastEpisode store.Episode
|
||||
enqueueCalls, statusCalls int
|
||||
statusIDs []string
|
||||
listStatuses []string
|
||||
listLimits []int
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeJobStore) ensure() {
|
||||
if f.jobs == nil {
|
||||
f.jobs = make(map[string]store.Job)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeJobStore) Enqueue(_ context.Context, ep store.Episode, source string) (store.Job, bool, error) {
|
||||
f.enqueueCalls++
|
||||
f.lastEpisode = ep
|
||||
if f.err != nil {
|
||||
return store.Job{}, false, f.err
|
||||
}
|
||||
f.ensure()
|
||||
if job, ok := f.jobs[ep.ID]; ok {
|
||||
return job, false, nil
|
||||
}
|
||||
f.nextID++
|
||||
job := store.Job{ID: f.nextID, EpisodeID: ep.ID, SeriesTitle: ep.SeriesTitle,
|
||||
Title: ep.Title, BroadcasterName: ep.BroadcasterName, Source: source,
|
||||
Status: "queued", CreatedAt: time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)}
|
||||
f.jobs[ep.ID] = job
|
||||
return job, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeJobStore) EpisodeStatuses(_ context.Context, ids []string) (map[string]string, error) {
|
||||
f.statusCalls++
|
||||
f.statusIDs = append([]string(nil), ids...)
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
result := make(map[string]string)
|
||||
for _, id := range ids {
|
||||
if job, ok := f.jobs[id]; ok {
|
||||
result[id] = job.Status
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (f *fakeJobStore) ListJobs(_ context.Context, status string, limit int) ([]store.Job, error) {
|
||||
f.listStatuses = append(f.listStatuses, status)
|
||||
f.listLimits = append(f.listLimits, limit)
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
var result []store.Job
|
||||
for _, job := range f.jobs {
|
||||
if job.Status == status {
|
||||
result = append(result, job)
|
||||
}
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].ID > result[j].ID })
|
||||
if len(result) > limit {
|
||||
result = result[:limit]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (f *fakeJobStore) GetJob(_ context.Context, id int64) (store.Job, error) {
|
||||
if f.err != nil {
|
||||
return store.Job{}, f.err
|
||||
}
|
||||
for _, job := range f.jobs {
|
||||
if job.ID == id {
|
||||
return job, nil
|
||||
}
|
||||
}
|
||||
return store.Job{}, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (f *fakeJobStore) Retry(_ context.Context, id int64) error {
|
||||
for ep, job := range f.jobs {
|
||||
if job.ID == id && (job.Status == "failed" || job.Status == "canceled") {
|
||||
job.Status = "queued"
|
||||
f.jobs[ep] = job
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return store.ErrInvalidTransition
|
||||
}
|
||||
|
||||
func (f *fakeJobStore) RetryEpisode(_ context.Context, id string) error {
|
||||
job, ok := f.jobs[id]
|
||||
if !ok || (job.Status != "failed" && job.Status != "canceled") {
|
||||
return store.ErrInvalidTransition
|
||||
}
|
||||
job.Status = "queued"
|
||||
f.jobs[id] = job
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeJobStore) Cancel(_ context.Context, id int64) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
for ep, job := range f.jobs {
|
||||
if job.ID == id && job.Status == "queued" {
|
||||
job.Status = "canceled"
|
||||
f.jobs[ep] = job
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return store.ErrInvalidTransition
|
||||
}
|
||||
|
||||
func (f *fakeJobStore) CancelQueued(ctx context.Context, id int64) error {
|
||||
return f.Cancel(ctx, id)
|
||||
}
|
||||
|
||||
var _ JobStore = (*fakeJobStore)(nil)
|
||||
@@ -20,6 +20,8 @@
|
||||
<main>
|
||||
{{if .Newer}}
|
||||
{{template "newer_page" .Newer}}
|
||||
{{else if .Queue}}
|
||||
{{template "queue_page" .Queue}}
|
||||
{{else}}
|
||||
<h1>{{.Title}}</h1>
|
||||
{{end}}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
{{define "newer_results"}}
|
||||
<section id="newer-results" aria-label="新着一覧" aria-live="polite">
|
||||
{{if .Failed}}
|
||||
<p role="status">新着を取得できませんでした</p>
|
||||
<p role="status">{{.ErrorText}}</p>
|
||||
{{else if not .Episodes}}
|
||||
<p>表示する新着はありません</p>
|
||||
{{else}}
|
||||
@@ -39,9 +39,31 @@
|
||||
<p>配信終了: <time datetime="{{.EndAtISO}}">{{.EndAtText}}</time></p>
|
||||
</div>
|
||||
</a>
|
||||
{{template "episode_action" .}}
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{define "episode_action"}}
|
||||
<div class="episode-action">
|
||||
{{if not .Status}}
|
||||
<form action="/episodes/enqueue" method="post" hx-post="/episodes/enqueue" hx-target="closest .episode-action" hx-swap="outerHTML">
|
||||
<input type="hidden" name="episode_id" value="{{.ID}}">
|
||||
<input type="hidden" name="genre" value="{{.Genre}}">
|
||||
<button type="submit">ダウンロード</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<span role="status">{{.StatusText}}</span>
|
||||
{{if or (eq .Status "failed") (eq .Status "canceled")}}
|
||||
<form action="/episodes/retry" method="post" hx-post="/episodes/retry" hx-target="closest .episode-action" hx-swap="outerHTML">
|
||||
<input type="hidden" name="episode_id" value="{{.ID}}">
|
||||
<input type="hidden" name="genre" value="{{.Genre}}">
|
||||
<button type="submit">再試行</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{{define "queue_page"}}
|
||||
<h1>待ち行列</h1>
|
||||
{{template "queue_list" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "queue_list"}}
|
||||
<div id="queue-list" {{if .Poll}}hx-get="/queue/list" hx-trigger="every 5s" hx-target="this" hx-swap="outerHTML"{{end}}>
|
||||
<section aria-label="ダウンロード中">
|
||||
<h2>ダウンロード中</h2>
|
||||
{{range .Running}}{{template "queue_job" .}}{{else}}<p>ありません</p>{{end}}
|
||||
</section>
|
||||
<section aria-label="待機中">
|
||||
<h2>待機中</h2>
|
||||
{{range .Queued}}{{template "queue_job" .}}{{else}}<p>ありません</p>{{end}}
|
||||
</section>
|
||||
<section aria-label="失敗">
|
||||
<h2>失敗</h2>
|
||||
{{range .Failed}}{{template "queue_job" .}}{{else}}<p>ありません</p>{{end}}
|
||||
</section>
|
||||
<section aria-label="取り消し">
|
||||
<h2>取り消し</h2>
|
||||
{{range .Canceled}}{{template "queue_job" .}}{{else}}<p>ありません</p>{{end}}
|
||||
</section>
|
||||
<section aria-label="完了">
|
||||
<h2>完了(直近50件)</h2>
|
||||
{{range .Succeeded}}{{template "queue_job" .}}{{else}}<p>ありません</p>{{end}}
|
||||
</section>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "queue_job"}}
|
||||
<article class="queue-job">
|
||||
<h3>{{.SeriesTitle}} — {{.Title}}</h3>
|
||||
<p>放送局: {{.BroadcasterName}}/登録元: {{.SourceText}}/登録日時: {{.CreatedAtText}}</p>
|
||||
{{if eq .Status "running"}}
|
||||
<p>段階: {{if .PhaseText}}{{.PhaseText}}{{else}}準備中{{end}}
|
||||
{{if .PercentText}}/{{.PercentText}}{{end}}
|
||||
{{if .SpeedText}}/{{.SpeedText}}{{end}}
|
||||
{{if .ETAText}}/{{.ETAText}}{{end}}
|
||||
{{if .FragmentText}}/{{.FragmentText}}{{end}}</p>
|
||||
{{end}}
|
||||
{{if eq .Status "failed"}}
|
||||
<p>失敗理由: {{.ErrorKindText}}</p>
|
||||
{{if .ErrorMessage}}<details><summary>詳細</summary><pre>{{.ErrorMessage}}</pre></details>{{end}}
|
||||
{{end}}
|
||||
{{if eq .Status "queued"}}
|
||||
<form action="/jobs/{{.ID}}/cancel" method="post" hx-post="/jobs/{{.ID}}/cancel" hx-target="#queue-list" hx-swap="outerHTML">
|
||||
<button type="submit">取り消し</button>
|
||||
</form>
|
||||
{{else if or (eq .Status "failed") (eq .Status "canceled")}}
|
||||
<form action="/jobs/{{.ID}}/retry" method="post" hx-post="/jobs/{{.ID}}/retry" hx-target="#queue-list" hx-swap="outerHTML">
|
||||
<button type="submit">再試行</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</article>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user