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 }