動画を確認画面つきで複数削除できるようにする(ステップ13)

This commit is contained in:
2026-10-15 23:27:51 +09:00
parent fd63a401f7
commit cb6fc1df4c
10 changed files with 609 additions and 62 deletions
+198 -26
View File
@@ -1,6 +1,7 @@
package web
import (
"context"
"errors"
"fmt"
"net/http"
@@ -53,6 +54,33 @@ type videoView struct {
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 {
@@ -235,49 +263,193 @@ func (s *server) serveVideo(w http.ResponseWriter, r *http.Request) {
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)
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) {
http.Error(w, "動画ファイルの場所が不正です", http.StatusConflict)
return
return errors.New("動画ファイルの場所が不正")
}
root, err := os.OpenRoot(s.videoDir)
if err != nil {
http.Error(w, "動画ファイルの場所が不正です", http.StatusConflict)
return
if root == nil {
return errors.New("動画ルートを開けない")
}
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
return fmt.Errorf("動画ファイルの検査: %w", err)
}
if err := root.Remove(*job.FilePath); err != nil && !errors.Is(err, os.ErrNotExist) {
s.logger.Printf("動画ファイルの削除に失敗: %v", err)
http.Error(w, "動画ファイルを削除できませんでした", http.StatusInternalServerError)
return
return fmt.Errorf("動画ファイルの削除: %w", err)
}
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
if err := s.jobs.DeleteSucceededJob(ctx, job.ID); err != nil {
return fmt.Errorf("動画の記録削除: %w", err)
}
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)
return nil
}
func (s *server) removeEmptyVideoParent(root *os.Root, filePath string) {