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) } func (s *server) cancelJob(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || id <= 0 { http.Error(w, "ジョブ ID が不正です", http.StatusBadRequest) return } 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" && job.Status != "running" { http.Error(w, "待機中またはダウンロード中のジョブだけ取り消せます", http.StatusConflict) return } s.queueMutation(w, r, s.jobs.Cancel) } func (s *server) queueMutation(w http.ResponseWriter, r *http.Request, change func(context.Context, int64) error) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || id <= 0 { 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.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) }