package web import ( "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 } 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) } func (s *server) deleteVideo(w http.ResponseWriter, r *http.Request) { job, ok := s.succeededJob(w, r) if !ok { return } if job.FilePath != nil { if !filepath.IsLocal(*job.FilePath) { http.Error(w, "動画ファイルの場所が不正です", http.StatusConflict) return } root, err := os.OpenRoot(s.videoDir) if err != nil { http.Error(w, "動画ファイルの場所が不正です", http.StatusConflict) return } defer root.Close() file, err := root.Open(*job.FilePath) if err == nil { _ = file.Close() } else if !errors.Is(err, os.ErrNotExist) { http.Error(w, "動画ファイルの場所が不正です", http.StatusConflict) return } if err := root.Remove(*job.FilePath); err != nil && !errors.Is(err, os.ErrNotExist) { s.logger.Printf("動画ファイルの削除に失敗: %v", err) http.Error(w, "動画ファイルを削除できませんでした", http.StatusInternalServerError) return } s.removeEmptyVideoParent(root, *job.FilePath) } if err := s.jobs.DeleteSucceededJob(r.Context(), job.ID); err != nil { s.logger.Printf("動画の記録削除に失敗: %v", err) http.Error(w, "動画の記録を削除できませんでした", http.StatusInternalServerError) return } location := "/library" remaining, err := s.jobs.ListSucceededJobsBySeries(r.Context(), job.SeriesID) if err != nil { s.logger.Printf("削除後の番組取得に失敗: %v", err) } else if len(remaining) > 0 { location = "/library/series/" + job.SeriesID } http.Redirect(w, r, location, http.StatusSeeOther) } 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 }