ライブラリを番組一覧・番組ページ・再生の3段にする(ステップ12)
This commit is contained in:
+179
-12
@@ -6,14 +6,13 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"gitea.kazu634.com/kazu634/tver/internal/store"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const libraryLimit = 100
|
||||
|
||||
type libraryItemView struct {
|
||||
ID int64
|
||||
ThumbnailURL, SeriesTitle, Title string
|
||||
@@ -21,27 +20,114 @@ type libraryItemView struct {
|
||||
FileSizeText, FinishedAtText string
|
||||
}
|
||||
|
||||
type librarySeriesView struct {
|
||||
SeriesID, SeriesTitle, LatestTitle string
|
||||
ThumbnailURL, BroadcasterName string
|
||||
EpisodeCount int64
|
||||
TotalSizeText, LatestFinishedText string
|
||||
Subscribed bool
|
||||
}
|
||||
|
||||
type libraryView struct {
|
||||
Items []libraryItemView
|
||||
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 string
|
||||
VideoURL, SeriesURL string
|
||||
Previous, Next *episodeLinkView
|
||||
}
|
||||
|
||||
func (s *server) libraryPage(w http.ResponseWriter, r *http.Request) {
|
||||
jobs, err := s.jobs.ListSucceededJobs(r.Context(), libraryLimit)
|
||||
series, err := s.jobs.ListLibrarySeries(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Printf("ライブラリの取得に失敗: %v", err)
|
||||
http.Error(w, "ライブラリを取得できませんでした", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
view := libraryView{Items: make([]libraryItemView, 0, len(jobs))}
|
||||
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))
|
||||
}
|
||||
s.render(w, "layout", page{Title: "ライブラリ", Library: &view})
|
||||
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) {
|
||||
@@ -49,13 +135,32 @@ func (s *server) videoPage(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item := s.libraryItem(job)
|
||||
s.render(w, "layout", page{Title: job.SeriesTitle + " " + job.Title,
|
||||
Video: &videoView{libraryItemView: item, VideoURL: "/videos/" + strconv.FormatInt(job.ID, 10)}})
|
||||
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: "https://statics.tver.jp" + job.ThumbnailPath,
|
||||
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 {
|
||||
@@ -67,6 +172,40 @@ func (s *server) libraryItem(job store.Job) libraryItemView {
|
||||
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 {
|
||||
@@ -124,13 +263,41 @@ func (s *server) deleteVideo(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
http.Redirect(w, r, "/library", http.StatusSeeOther)
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user