493 lines
15 KiB
Go
493 lines
15 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
|
|
"gitea.kazu634.com/kazu634/tver/internal/store"
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
type libraryItemView struct {
|
|
ID int64
|
|
ThumbnailURL, SeriesTitle, Title string
|
|
BroadcasterName, DurationText string
|
|
FileSizeText, FinishedAtText string
|
|
}
|
|
|
|
type librarySeriesView struct {
|
|
SeriesID, SeriesTitle, LatestTitle string
|
|
ThumbnailURL, BroadcasterName string
|
|
EpisodeCount int64
|
|
TotalSizeText, LatestFinishedText string
|
|
Subscribed bool
|
|
}
|
|
|
|
type libraryView struct {
|
|
Series []librarySeriesView
|
|
SeriesCount, EpisodeCount int
|
|
TotalSizeText string
|
|
}
|
|
|
|
type seriesView struct {
|
|
SeriesID, SeriesTitle, BroadcasterName string
|
|
EpisodeCount int
|
|
TotalSizeText, Sort string
|
|
Subscribed bool
|
|
Items []libraryItemView
|
|
}
|
|
|
|
type episodeLinkView struct {
|
|
ID int64
|
|
Title string
|
|
}
|
|
|
|
type videoView struct {
|
|
libraryItemView
|
|
VideoURL, SeriesURL string
|
|
Previous, Next *episodeLinkView
|
|
}
|
|
|
|
type deleteGroupView struct {
|
|
SeriesID, SeriesTitle, TotalSizeText string
|
|
Items []libraryItemView
|
|
}
|
|
|
|
type deleteConfirmView struct {
|
|
Groups []deleteGroupView
|
|
IDs []int64
|
|
Count int
|
|
TotalSizeText string
|
|
CancelURL string
|
|
}
|
|
|
|
type deleteFailureView struct {
|
|
Title, Reason string
|
|
}
|
|
|
|
type deleteSeriesLinkView struct {
|
|
SeriesID, SeriesTitle string
|
|
}
|
|
|
|
type deleteResultView struct {
|
|
DeletedCount, ExcludedCount int
|
|
Failures []deleteFailureView
|
|
SeriesLinks []deleteSeriesLinkView
|
|
}
|
|
|
|
func (s *server) libraryPage(w http.ResponseWriter, r *http.Request) {
|
|
series, err := s.jobs.ListLibrarySeries(r.Context())
|
|
if err != nil {
|
|
s.logger.Printf("ライブラリの取得に失敗: %v", err)
|
|
http.Error(w, "ライブラリを取得できませんでした", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
view := libraryView{Series: make([]librarySeriesView, 0, len(series)), SeriesCount: len(series)}
|
|
var totalBytes int64
|
|
for _, item := range series {
|
|
view.EpisodeCount += int(item.EpisodeCount)
|
|
totalBytes += item.TotalSizeBytes
|
|
viewItem := librarySeriesView{
|
|
SeriesID: item.SeriesID, SeriesTitle: item.SeriesTitle, LatestTitle: item.LatestTitle,
|
|
ThumbnailURL: thumbnailURL(item.ThumbnailPath), BroadcasterName: item.BroadcasterName,
|
|
EpisodeCount: item.EpisodeCount, TotalSizeText: formatBytes(float64(item.TotalSizeBytes)),
|
|
Subscribed: item.Subscribed,
|
|
}
|
|
if item.LatestFinishedAt != nil {
|
|
viewItem.LatestFinishedText = item.LatestFinishedAt.In(s.zone).Format("2006-01-02 15:04 MST")
|
|
}
|
|
view.Series = append(view.Series, viewItem)
|
|
}
|
|
view.TotalSizeText = fmt.Sprintf("%.2f GB", float64(totalBytes)/1_000_000_000)
|
|
s.render(w, "layout", page{Title: "ライブラリ", Library: &view})
|
|
}
|
|
|
|
func (s *server) librarySeriesPage(w http.ResponseWriter, r *http.Request) {
|
|
seriesID := r.PathValue("series_id")
|
|
if !seriesIDPattern.MatchString(seriesID) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
sortOrder := r.URL.Query().Get("sort")
|
|
if sortOrder == "" {
|
|
sortOrder = "latest"
|
|
}
|
|
if sortOrder != "latest" && sortOrder != "natural" {
|
|
http.Error(w, "並び順が不正です", http.StatusBadRequest)
|
|
return
|
|
}
|
|
jobs, err := s.jobs.ListSucceededJobsBySeries(r.Context(), seriesID)
|
|
if err != nil {
|
|
s.logger.Printf("番組のライブラリ取得に失敗: %v", err)
|
|
http.Error(w, "番組を取得できませんでした", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if len(jobs) == 0 {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
latest := latestFinishedJob(jobs)
|
|
if sortOrder == "natural" {
|
|
sortJobsNaturally(jobs)
|
|
} else {
|
|
sortJobsLatest(jobs)
|
|
}
|
|
subscribed, err := s.jobs.SubscribedSeries(r.Context(), []string{seriesID})
|
|
if err != nil {
|
|
s.logger.Printf("番組登録状態の取得に失敗: %v", err)
|
|
http.Error(w, "番組を取得できませんでした", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
view := seriesView{SeriesID: seriesID, SeriesTitle: latest.SeriesTitle,
|
|
BroadcasterName: latest.BroadcasterName, EpisodeCount: len(jobs),
|
|
Subscribed: subscribed[seriesID], Sort: sortOrder, Items: make([]libraryItemView, 0, len(jobs))}
|
|
var totalBytes int64
|
|
for _, job := range jobs {
|
|
if job.FileSizeBytes != nil {
|
|
totalBytes += *job.FileSizeBytes
|
|
}
|
|
view.Items = append(view.Items, s.libraryItem(job))
|
|
}
|
|
view.TotalSizeText = formatBytes(float64(totalBytes))
|
|
s.render(w, "layout", page{Title: latest.SeriesTitle, Series: &view})
|
|
}
|
|
|
|
func (s *server) videoPage(w http.ResponseWriter, r *http.Request) {
|
|
job, ok := s.succeededJob(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
jobs, err := s.jobs.ListSucceededJobsBySeries(r.Context(), job.SeriesID)
|
|
if err != nil {
|
|
s.logger.Printf("前後の回の取得に失敗: %v", err)
|
|
http.Error(w, "動画を取得できませんでした", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
sortJobsNaturally(jobs)
|
|
view := videoView{libraryItemView: s.libraryItem(job), VideoURL: "/videos/" + strconv.FormatInt(job.ID, 10),
|
|
SeriesURL: "/library/series/" + job.SeriesID}
|
|
for i := range jobs {
|
|
if jobs[i].ID != job.ID {
|
|
continue
|
|
}
|
|
if i > 0 {
|
|
view.Previous = &episodeLinkView{ID: jobs[i-1].ID, Title: jobs[i-1].Title}
|
|
}
|
|
if i+1 < len(jobs) {
|
|
view.Next = &episodeLinkView{ID: jobs[i+1].ID, Title: jobs[i+1].Title}
|
|
}
|
|
break
|
|
}
|
|
s.render(w, "layout", page{Title: job.SeriesTitle + " " + job.Title, Video: &view})
|
|
}
|
|
|
|
func (s *server) libraryItem(job store.Job) libraryItemView {
|
|
item := libraryItemView{ID: job.ID, ThumbnailURL: thumbnailURL(job.ThumbnailPath),
|
|
SeriesTitle: job.SeriesTitle, Title: job.Title, BroadcasterName: job.BroadcasterName,
|
|
DurationText: fmt.Sprintf("%d分", durationMinutes(int64(job.DurationSeconds)))}
|
|
if job.FileSizeBytes != nil {
|
|
item.FileSizeText = formatBytes(float64(*job.FileSizeBytes))
|
|
}
|
|
if job.FinishedAt != nil {
|
|
item.FinishedAtText = job.FinishedAt.In(s.zone).Format("2006-01-02 15:04 MST")
|
|
}
|
|
return item
|
|
}
|
|
|
|
func thumbnailURL(path string) string {
|
|
if path == "" {
|
|
return ""
|
|
}
|
|
return "https://statics.tver.jp" + path
|
|
}
|
|
|
|
func latestFinishedJob(jobs []store.Job) store.Job {
|
|
latest := jobs[0]
|
|
for _, job := range jobs[1:] {
|
|
if jobFinishedLater(job, latest) {
|
|
latest = job
|
|
}
|
|
}
|
|
return latest
|
|
}
|
|
|
|
func sortJobsLatest(jobs []store.Job) {
|
|
sort.SliceStable(jobs, func(i, j int) bool { return jobFinishedLater(jobs[i], jobs[j]) })
|
|
}
|
|
|
|
func jobFinishedLater(left, right store.Job) bool {
|
|
if left.FinishedAt == nil || right.FinishedAt == nil {
|
|
if left.FinishedAt != right.FinishedAt {
|
|
return left.FinishedAt != nil
|
|
}
|
|
return left.ID > right.ID
|
|
}
|
|
if !left.FinishedAt.Equal(*right.FinishedAt) {
|
|
return left.FinishedAt.After(*right.FinishedAt)
|
|
}
|
|
return left.ID > right.ID
|
|
}
|
|
|
|
func (s *server) serveVideo(w http.ResponseWriter, r *http.Request) {
|
|
job, ok := s.succeededJob(w, r)
|
|
if !ok || job.FilePath == nil {
|
|
if ok {
|
|
http.NotFound(w, r)
|
|
}
|
|
return
|
|
}
|
|
root, err := os.OpenRoot(s.videoDir)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
defer root.Close()
|
|
file, err := root.Open(*job.FilePath)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
info, err := file.Stat()
|
|
if err != nil || !info.Mode().IsRegular() {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "video/mp4")
|
|
http.ServeContent(w, r, filepath.Base(*job.FilePath), info.ModTime(), file)
|
|
}
|
|
|
|
const maxDeleteSelections = 1000
|
|
|
|
func (s *server) confirmDeleteVideos(w http.ResponseWriter, r *http.Request) {
|
|
ids, seriesIDs, ok := parseDeleteSelection(w, r, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
jobsByID, err := s.jobs.ListSucceededJobsByIDs(r.Context(), ids)
|
|
if err != nil {
|
|
s.logger.Printf("削除対象IDの取得に失敗: %v", err)
|
|
http.Error(w, "削除対象を取得できませんでした", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
jobsBySeries, err := s.jobs.ListSucceededJobsBySeriesIDs(r.Context(), seriesIDs)
|
|
if err != nil {
|
|
s.logger.Printf("削除対象番組の取得に失敗: %v", err)
|
|
http.Error(w, "削除対象を取得できませんでした", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
byID := make(map[int64]store.Job, len(jobsByID)+len(jobsBySeries))
|
|
for _, job := range append(jobsByID, jobsBySeries...) {
|
|
byID[job.ID] = job
|
|
}
|
|
if len(byID) == 0 {
|
|
http.Error(w, "削除対象がありません", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if len(byID) > maxDeleteSelections {
|
|
http.Error(w, fmt.Sprintf("削除対象が多すぎます(%d本)。番組を分けて選んでください", len(byID)), http.StatusBadRequest)
|
|
return
|
|
}
|
|
jobs := make([]store.Job, 0, len(byID))
|
|
for _, job := range byID {
|
|
jobs = append(jobs, job)
|
|
}
|
|
sort.Slice(jobs, func(i, j int) bool {
|
|
if jobs[i].SeriesTitle != jobs[j].SeriesTitle {
|
|
return jobs[i].SeriesTitle < jobs[j].SeriesTitle
|
|
}
|
|
return jobs[i].ID < jobs[j].ID
|
|
})
|
|
view := deleteConfirmView{IDs: make([]int64, 0, len(jobs)), Count: len(jobs), CancelURL: "/library"}
|
|
groupIndexes := make(map[string]int)
|
|
groupBytes := make(map[string]int64)
|
|
var totalBytes int64
|
|
for _, job := range jobs {
|
|
view.IDs = append(view.IDs, job.ID)
|
|
index, exists := groupIndexes[job.SeriesID]
|
|
if !exists {
|
|
index = len(view.Groups)
|
|
groupIndexes[job.SeriesID] = index
|
|
view.Groups = append(view.Groups, deleteGroupView{SeriesID: job.SeriesID, SeriesTitle: job.SeriesTitle})
|
|
}
|
|
view.Groups[index].Items = append(view.Groups[index].Items, s.libraryItem(job))
|
|
if job.FileSizeBytes != nil {
|
|
totalBytes += *job.FileSizeBytes
|
|
groupBytes[job.SeriesID] += *job.FileSizeBytes
|
|
}
|
|
}
|
|
for i := range view.Groups {
|
|
view.Groups[i].TotalSizeText = formatBytes(float64(groupBytes[view.Groups[i].SeriesID]))
|
|
}
|
|
view.TotalSizeText = formatBytes(float64(totalBytes))
|
|
if len(view.Groups) == 1 {
|
|
view.CancelURL = "/library/series/" + view.Groups[0].SeriesID
|
|
}
|
|
s.render(w, "layout", page{Title: "動画の削除確認", DeleteConfirm: &view})
|
|
}
|
|
|
|
func parseDeleteSelection(w http.ResponseWriter, r *http.Request, includeSeries bool) ([]int64, []string, bool) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "入力が不正です", http.StatusBadRequest)
|
|
return nil, nil, false
|
|
}
|
|
idSet := make(map[int64]struct{})
|
|
for _, raw := range r.Form["id"] {
|
|
id, err := strconv.ParseInt(raw, 10, 64)
|
|
if err != nil || id <= 0 {
|
|
http.Error(w, "動画IDが不正です", http.StatusBadRequest)
|
|
return nil, nil, false
|
|
}
|
|
idSet[id] = struct{}{}
|
|
}
|
|
seriesSet := make(map[string]struct{})
|
|
if includeSeries {
|
|
for _, seriesID := range r.Form["series_id"] {
|
|
if !seriesIDPattern.MatchString(seriesID) {
|
|
http.Error(w, "番組IDが不正です", http.StatusBadRequest)
|
|
return nil, nil, false
|
|
}
|
|
seriesSet[seriesID] = struct{}{}
|
|
}
|
|
}
|
|
if len(idSet)+len(seriesSet) == 0 {
|
|
http.Error(w, "削除対象がありません", http.StatusBadRequest)
|
|
return nil, nil, false
|
|
}
|
|
if len(idSet)+len(seriesSet) > maxDeleteSelections {
|
|
http.Error(w, "削除対象が多すぎます", http.StatusBadRequest)
|
|
return nil, nil, false
|
|
}
|
|
ids := make([]int64, 0, len(idSet))
|
|
for id := range idSet {
|
|
ids = append(ids, id)
|
|
}
|
|
seriesIDs := make([]string, 0, len(seriesSet))
|
|
for seriesID := range seriesSet {
|
|
seriesIDs = append(seriesIDs, seriesID)
|
|
}
|
|
return ids, seriesIDs, true
|
|
}
|
|
|
|
func (s *server) deleteVideos(w http.ResponseWriter, r *http.Request) {
|
|
ids, _, ok := parseDeleteSelection(w, r, false)
|
|
if !ok {
|
|
return
|
|
}
|
|
jobs, err := s.jobs.ListSucceededJobsByIDs(r.Context(), ids)
|
|
if err != nil {
|
|
s.logger.Printf("削除対象の取得に失敗: %v", err)
|
|
http.Error(w, "削除対象を取得できませんでした", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
view := deleteResultView{ExcludedCount: len(ids) - len(jobs)}
|
|
var root *os.Root
|
|
needsRoot := false
|
|
for _, job := range jobs {
|
|
needsRoot = needsRoot || job.FilePath != nil
|
|
}
|
|
if needsRoot {
|
|
root, err = os.OpenRoot(s.videoDir)
|
|
if err == nil {
|
|
defer root.Close()
|
|
}
|
|
}
|
|
affectedSeries := make(map[string]string)
|
|
for _, job := range jobs {
|
|
affectedSeries[job.SeriesID] = job.SeriesTitle
|
|
if err != nil && job.FilePath != nil {
|
|
s.logger.Printf("動画ルートを開けないため削除に失敗 id=%d: %v", job.ID, err)
|
|
view.Failures = append(view.Failures, deleteFailureView{Title: job.Title, Reason: "動画ファイルを削除できませんでした"})
|
|
continue
|
|
}
|
|
if deleteErr := s.deleteJob(r.Context(), root, job); deleteErr != nil {
|
|
s.logger.Printf("動画の削除に失敗 id=%d: %v", job.ID, deleteErr)
|
|
view.Failures = append(view.Failures, deleteFailureView{Title: job.Title, Reason: "削除できませんでした"})
|
|
continue
|
|
}
|
|
view.DeletedCount++
|
|
}
|
|
for seriesID, title := range affectedSeries {
|
|
remaining, listErr := s.jobs.ListSucceededJobsBySeries(r.Context(), seriesID)
|
|
if listErr != nil {
|
|
s.logger.Printf("削除後の番組取得に失敗 series_id=%s: %v", seriesID, listErr)
|
|
continue
|
|
}
|
|
if len(remaining) > 0 {
|
|
view.SeriesLinks = append(view.SeriesLinks, deleteSeriesLinkView{SeriesID: seriesID, SeriesTitle: title})
|
|
}
|
|
}
|
|
sort.Slice(view.SeriesLinks, func(i, j int) bool { return view.SeriesLinks[i].SeriesTitle < view.SeriesLinks[j].SeriesTitle })
|
|
s.render(w, "layout", page{Title: "動画の削除結果", DeleteResult: &view})
|
|
}
|
|
|
|
func (s *server) deleteJob(ctx context.Context, root *os.Root, job store.Job) error {
|
|
if job.FilePath != nil {
|
|
if !filepath.IsLocal(*job.FilePath) {
|
|
return errors.New("動画ファイルの場所が不正")
|
|
}
|
|
if root == nil {
|
|
return errors.New("動画ルートを開けない")
|
|
}
|
|
file, err := root.Open(*job.FilePath)
|
|
if err == nil {
|
|
_ = file.Close()
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("動画ファイルの検査: %w", err)
|
|
}
|
|
if err := root.Remove(*job.FilePath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("動画ファイルの削除: %w", err)
|
|
}
|
|
s.removeEmptyVideoParent(root, *job.FilePath)
|
|
}
|
|
if err := s.jobs.DeleteSucceededJob(ctx, job.ID); err != nil {
|
|
return fmt.Errorf("動画の記録削除: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *server) removeEmptyVideoParent(root *os.Root, filePath string) {
|
|
parent := filepath.Dir(filePath)
|
|
if parent == "." || !filepath.IsLocal(parent) {
|
|
return
|
|
}
|
|
info, err := root.Lstat(parent)
|
|
if err != nil {
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
s.logger.Printf("動画フォルダの確認に失敗: %v", err)
|
|
}
|
|
return
|
|
}
|
|
if !info.IsDir() {
|
|
return
|
|
}
|
|
if err := root.Remove(parent); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
s.logger.Printf("空の動画フォルダを削除できませんでした: %v", err)
|
|
}
|
|
}
|
|
|
|
func (s *server) succeededJob(w http.ResponseWriter, r *http.Request) (store.Job, bool) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil || id <= 0 {
|
|
http.NotFound(w, r)
|
|
return store.Job{}, false
|
|
}
|
|
job, err := s.jobs.GetSucceededJob(r.Context(), id)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
http.NotFound(w, r)
|
|
return store.Job{}, false
|
|
}
|
|
if err != nil {
|
|
s.logger.Printf("完了ジョブの取得に失敗: %v", err)
|
|
http.Error(w, "動画を取得できませんでした", http.StatusInternalServerError)
|
|
return store.Job{}, false
|
|
}
|
|
return job, true
|
|
}
|