動画を確認画面つきで複数削除できるようにする(ステップ13)
This commit is contained in:
@@ -24,6 +24,8 @@ type page struct {
|
||||
Library *libraryView
|
||||
Series *seriesView
|
||||
Video *videoView
|
||||
DeleteConfirm *deleteConfirmView
|
||||
DeleteResult *deleteResultView
|
||||
}
|
||||
|
||||
type JobStore interface {
|
||||
@@ -42,6 +44,8 @@ type JobStore interface {
|
||||
ListSucceededJobs(context.Context, int) ([]store.Job, error)
|
||||
ListLibrarySeries(context.Context) ([]store.LibrarySeries, error)
|
||||
ListSucceededJobsBySeries(context.Context, string) ([]store.Job, error)
|
||||
ListSucceededJobsByIDs(context.Context, []int64) ([]store.Job, error)
|
||||
ListSucceededJobsBySeriesIDs(context.Context, []string) ([]store.Job, error)
|
||||
GetSucceededJob(context.Context, int64) (store.Job, error)
|
||||
DeleteSucceededJob(context.Context, int64) error
|
||||
}
|
||||
@@ -100,6 +104,7 @@ func NewHandler(fetcher NewerFetcher, jobs JobStore, videoDir string, now func()
|
||||
mux.HandleFunc("GET /library/series/{series_id}", s.librarySeriesPage)
|
||||
mux.HandleFunc("GET /library/{id}", s.videoPage)
|
||||
mux.HandleFunc("GET /videos/{id}", s.serveVideo)
|
||||
mux.HandleFunc("POST /library/{id}/delete", s.deleteVideo)
|
||||
mux.HandleFunc("POST /library/delete/confirm", s.confirmDeleteVideos)
|
||||
mux.HandleFunc("POST /library/delete", s.deleteVideos)
|
||||
return mux
|
||||
}
|
||||
|
||||
+198
-26
@@ -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) {
|
||||
|
||||
+216
-22
@@ -4,8 +4,10 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -39,21 +41,15 @@ func TestLibraryAndPlaybackPage(t *testing.T) {
|
||||
t.Fatal("library fields were not escaped")
|
||||
}
|
||||
video := getPage(handler, "/library/1")
|
||||
if video.Code != http.StatusOK || !strings.Contains(video.Body.String(), `<video controls`) || !strings.Contains(video.Body.String(), `src="/videos/1"`) || !strings.Contains(video.Body.String(), `hx-confirm="動画ファイルと記録を削除しますか?"`) || !strings.Contains(video.Body.String(), `/library/series/series1`) || !strings.Contains(video.Body.String(), `前の回はありません`) || !strings.Contains(video.Body.String(), `次の回はありません`) {
|
||||
if video.Code != http.StatusOK || !strings.Contains(video.Body.String(), `<video controls`) || !strings.Contains(video.Body.String(), `src="/videos/1"`) || !strings.Contains(video.Body.String(), `action="/library/delete/confirm"`) || strings.Contains(video.Body.String(), `hx-confirm`) || !strings.Contains(video.Body.String(), `/library/series/series1`) || !strings.Contains(video.Body.String(), `前の回はありません`) || !strings.Contains(video.Body.String(), `次の回はありません`) {
|
||||
t.Fatalf("video page: %d %s", video.Code, video.Body.String())
|
||||
}
|
||||
for _, path := range []string{"/library/bad", "/library/99", "/library/1/delete"} {
|
||||
response := getPage(handler, path)
|
||||
want := http.StatusNotFound
|
||||
if strings.HasSuffix(path, "/delete") {
|
||||
want = http.StatusMethodNotAllowed
|
||||
}
|
||||
if response.Code != want {
|
||||
t.Errorf("%s status=%d want=%d", path, response.Code, want)
|
||||
}
|
||||
if want == http.StatusMethodNotAllowed && !strings.Contains(response.Header().Get("Allow"), "POST") {
|
||||
t.Errorf("%s Allow=%q", path, response.Header().Get("Allow"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +167,7 @@ func TestLibraryRejectsInvalidJobIDs(t *testing.T) {
|
||||
t.Errorf("GET %s: %d", path, got)
|
||||
}
|
||||
}
|
||||
if got := postForm(handler, "/library/"+id+"/delete", nil).Code; got != http.StatusNotFound {
|
||||
if got := postForm(handler, "/library/delete", url.Values{"id": {id}}).Code; got != http.StatusBadRequest {
|
||||
t.Errorf("POST delete id=%q: %d", id, got)
|
||||
}
|
||||
}
|
||||
@@ -262,8 +258,8 @@ func TestVideoPathTraversalAndSymlinkAreRejected(t *testing.T) {
|
||||
if got := getPage(handler, "/videos/1").Code; got != http.StatusNotFound {
|
||||
t.Errorf("path %q status=%d", relative, got)
|
||||
}
|
||||
deleted := postForm(handler, "/library/1/delete", nil)
|
||||
if deleted.Code != http.StatusConflict || len(jobs.jobs) != 1 {
|
||||
deleted := postForm(handler, "/library/delete", url.Values{"id": {"1"}})
|
||||
if deleted.Code != http.StatusOK || len(jobs.jobs) != 1 || !strings.Contains(deleted.Body.String(), "失敗: 1件") {
|
||||
t.Errorf("delete path %q status=%d jobs=%d", relative, deleted.Code, len(jobs.jobs))
|
||||
}
|
||||
}
|
||||
@@ -291,7 +287,7 @@ func TestVideoSymlinkWithinRootCanBeServedAndDeleted(t *testing.T) {
|
||||
if response := getPage(handler, "/videos/1"); response.Code != http.StatusOK || response.Body.String() != "video" {
|
||||
t.Fatalf("serve: status=%d body=%q", response.Code, response.Body.String())
|
||||
}
|
||||
if response := postForm(handler, "/library/1/delete", nil); response.Code != http.StatusSeeOther || len(jobs.jobs) != 0 {
|
||||
if response := postForm(handler, "/library/delete", url.Values{"id": {"1"}}); response.Code != http.StatusOK || len(jobs.jobs) != 0 {
|
||||
t.Fatalf("delete: status=%d jobs=%d", response.Code, len(jobs.jobs))
|
||||
}
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
@@ -319,7 +315,7 @@ func TestVideoSymlinkedDirectoryWithinRootCanBeServedAndDeleted(t *testing.T) {
|
||||
if response := getPage(handler, "/videos/1"); response.Code != http.StatusOK || response.Body.String() != "video" {
|
||||
t.Fatalf("serve: status=%d body=%q", response.Code, response.Body.String())
|
||||
}
|
||||
if response := postForm(handler, "/library/1/delete", nil); response.Code != http.StatusSeeOther || len(jobs.jobs) != 0 {
|
||||
if response := postForm(handler, "/library/delete", url.Values{"id": {"1"}}); response.Code != http.StatusOK || len(jobs.jobs) != 0 {
|
||||
t.Fatalf("delete: status=%d jobs=%d", response.Code, len(jobs.jobs))
|
||||
}
|
||||
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
|
||||
@@ -344,7 +340,7 @@ func TestVideoAbsoluteSymlinkWithinRootIsRejectedByOSRoot(t *testing.T) {
|
||||
if got := getPage(handler, "/videos/1").Code; got != http.StatusNotFound {
|
||||
t.Fatalf("absolute symlink serve status=%d", got)
|
||||
}
|
||||
if response := postForm(handler, "/library/1/delete", nil); response.Code != http.StatusConflict || len(jobs.jobs) != 1 {
|
||||
if response := postForm(handler, "/library/delete", url.Values{"id": {"1"}}); response.Code != http.StatusOK || len(jobs.jobs) != 1 || !strings.Contains(response.Body.String(), "失敗: 1件") {
|
||||
t.Fatalf("absolute symlink delete: status=%d jobs=%d", response.Code, len(jobs.jobs))
|
||||
}
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
@@ -368,8 +364,8 @@ func TestDeleteVideoFileThenRecord(t *testing.T) {
|
||||
fileWasGoneBeforeRecordDelete = errors.Is(err, os.ErrNotExist)
|
||||
}
|
||||
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
||||
response := postForm(handler, "/library/1/delete", nil)
|
||||
if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/library" || len(jobs.jobs) != 0 || !fileWasGoneBeforeRecordDelete {
|
||||
response := postForm(handler, "/library/delete", url.Values{"id": {"1"}})
|
||||
if response.Code != http.StatusOK || len(jobs.jobs) != 0 || !fileWasGoneBeforeRecordDelete || !strings.Contains(response.Body.String(), "削除: 1件") {
|
||||
t.Fatalf("delete: status=%d location=%q jobs=%d", response.Code, response.Header().Get("Location"), len(jobs.jobs))
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
@@ -379,7 +375,7 @@ func TestDeleteVideoFileThenRecord(t *testing.T) {
|
||||
t.Fatalf("empty series folder remains: %v", err)
|
||||
}
|
||||
jobs.jobs["ep1"] = libraryJob(1, "show/missing.mp4")
|
||||
if got := postForm(handler, "/library/1/delete", nil).Code; got != http.StatusSeeOther || len(jobs.jobs) != 0 {
|
||||
if got := postForm(handler, "/library/delete", url.Values{"id": {"1"}}).Code; got != http.StatusOK || len(jobs.jobs) != 0 {
|
||||
t.Fatalf("missing file delete: status=%d jobs=%d", got, len(jobs.jobs))
|
||||
}
|
||||
}
|
||||
@@ -400,8 +396,8 @@ func TestDeleteRedirectsToSeriesWhenEpisodesRemainAndKeepsNonemptyFolder(t *test
|
||||
second.EpisodeID = "ep2"
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": first, "ep2": second}}
|
||||
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
||||
response := postForm(handler, "/library/1/delete", nil)
|
||||
if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/library/series/series1" || len(jobs.jobs) != 1 {
|
||||
response := postForm(handler, "/library/delete", url.Values{"id": {"1"}})
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "/library/series/series1") || len(jobs.jobs) != 1 {
|
||||
t.Fatalf("delete: status=%d location=%q jobs=%d", response.Code, response.Header().Get("Location"), len(jobs.jobs))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "show")); err != nil {
|
||||
@@ -418,8 +414,8 @@ func TestDeleteVideoAtRootNeverRemovesVideoRoot(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "video.mp4")}}
|
||||
response := postForm(newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil), "/library/1/delete", nil)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
response := postForm(newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil), "/library/delete", url.Values{"id": {"1"}})
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("delete status=%d", response.Code)
|
||||
}
|
||||
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
|
||||
@@ -438,8 +434,206 @@ func TestDeleteFailureKeepsRecord(t *testing.T) {
|
||||
}
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "blocked")}}
|
||||
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
||||
response := postForm(handler, "/library/1/delete", nil)
|
||||
if response.Code != http.StatusInternalServerError || len(jobs.jobs) != 1 || !strings.Contains(response.Body.String(), "動画ファイルを削除できませんでした") {
|
||||
response := postForm(handler, "/library/delete", url.Values{"id": {"1"}})
|
||||
if response.Code != http.StatusOK || len(jobs.jobs) != 1 || !strings.Contains(response.Body.String(), "失敗: 1件") {
|
||||
t.Fatalf("failure: status=%d jobs=%d body=%s", response.Code, len(jobs.jobs), response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteConfirmationSelectionValidationAndDeduplication(t *testing.T) {
|
||||
first := libraryJob(1, "show/one.mp4")
|
||||
second := libraryJob(2, "show/two.mp4")
|
||||
second.EpisodeID = "ep2"
|
||||
second.Title = `<img src=x onerror=alert(1)>`
|
||||
third := libraryJob(3, "other/three.mp4")
|
||||
third.EpisodeID = "ep3"
|
||||
third.SeriesID = "series2"
|
||||
third.SeriesTitle = "別番組"
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": first, "ep2": second, "ep3": third}}
|
||||
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, t.TempDir(), nil)
|
||||
|
||||
for name, values := range map[string]url.Values{
|
||||
"IDのみ": {"id": {"1"}},
|
||||
"番組のみ": {"series_id": {"series2"}},
|
||||
"両方と重複除去": {"id": {"1"}, "series_id": {"series1"}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
response := postForm(handler, "/library/delete/confirm", values)
|
||||
if response.Code != http.StatusOK || len(jobs.jobs) != 3 {
|
||||
t.Fatalf("status=%d jobs=%d body=%s", response.Code, len(jobs.jobs), response.Body.String())
|
||||
}
|
||||
if strings.Contains(response.Body.String(), `<img src=x`) || strings.Contains(response.Body.String(), `<script>番組</script>`) {
|
||||
t.Fatal("確認画面でHTMLがエスケープされていない")
|
||||
}
|
||||
if name == "両方と重複除去" && (!strings.Contains(response.Body.String(), `<script>番組</script>`) || !strings.Contains(response.Body.String(), `<img src=x onerror=alert(1)>`)) {
|
||||
t.Fatalf("番組名または回タイトルのエスケープ結果がない: %s", response.Body.String())
|
||||
}
|
||||
if name == "両方と重複除去" && strings.Count(response.Body.String(), `name="id"`) != 2 {
|
||||
t.Fatalf("重複除去後のhidden ID: %s", response.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
for name, values := range map[string]url.Values{
|
||||
"対象なし": {},
|
||||
"該当なし": {"id": {"99"}},
|
||||
"不正ID": {"id": {"bad"}},
|
||||
"ゼロID": {"id": {"0"}},
|
||||
"不正番組ID": {"series_id": {"bad-id"}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if got := postForm(handler, "/library/delete/confirm", values).Code; got != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
tooMany := url.Values{}
|
||||
for i := 1; i <= maxDeleteSelections+1; i++ {
|
||||
tooMany.Add("id", strconv.Itoa(i))
|
||||
}
|
||||
if got := postForm(handler, "/library/delete/confirm", tooMany).Code; got != http.StatusBadRequest {
|
||||
t.Fatalf("上限超過 status=%d", got)
|
||||
}
|
||||
atLimit := url.Values{}
|
||||
for i := 1; i <= maxDeleteSelections; i++ {
|
||||
atLimit.Add("id", strconv.Itoa(i))
|
||||
}
|
||||
if response := postForm(handler, "/library/delete/confirm", atLimit); response.Code != http.StatusOK || len(jobs.jobs) != 3 {
|
||||
t.Fatalf("入力上限ちょうど: status=%d jobs=%d", response.Code, len(jobs.jobs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteConfirmationRejectsExpandedSelectionOverLimit(t *testing.T) {
|
||||
makeJobs := func(count int) map[string]store.Job {
|
||||
jobs := make(map[string]store.Job, count)
|
||||
for i := 1; i <= count; i++ {
|
||||
job := libraryJob(int64(i), "show/video.mp4")
|
||||
job.EpisodeID = "ep" + strconv.Itoa(i)
|
||||
jobs[job.EpisodeID] = job
|
||||
}
|
||||
return jobs
|
||||
}
|
||||
|
||||
atLimit := &fakeJobStore{jobs: makeJobs(maxDeleteSelections)}
|
||||
response := postForm(newTestHandlerWithVideoDir(&fakeFetcher{}, atLimit, t.TempDir(), nil),
|
||||
"/library/delete/confirm", url.Values{"series_id": {"series1"}})
|
||||
if response.Code != http.StatusOK || len(atLimit.jobs) != maxDeleteSelections {
|
||||
t.Fatalf("上限ちょうど: status=%d jobs=%d", response.Code, len(atLimit.jobs))
|
||||
}
|
||||
|
||||
overLimit := &fakeJobStore{jobs: makeJobs(maxDeleteSelections + 1)}
|
||||
response = postForm(newTestHandlerWithVideoDir(&fakeFetcher{}, overLimit, t.TempDir(), nil),
|
||||
"/library/delete/confirm", url.Values{"series_id": {"series1"}})
|
||||
if response.Code != http.StatusBadRequest || len(overLimit.jobs) != maxDeleteSelections+1 ||
|
||||
!strings.Contains(response.Body.String(), strconv.Itoa(maxDeleteSelections+1)+"本") ||
|
||||
!strings.Contains(response.Body.String(), "番組を分けて選んでください") {
|
||||
t.Fatalf("上限超過: status=%d jobs=%d body=%s", response.Code, len(overLimit.jobs), response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkDeleteContinuesAfterFailureAndDoesNotExpandSeries(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "show"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "show", "one.mp4"), []byte("one"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(dir, "show", "blocked"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "show", "blocked", "child"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := libraryJob(1, "show/one.mp4")
|
||||
blocked := libraryJob(2, "show/blocked")
|
||||
blocked.EpisodeID = "ep2"
|
||||
blocked.Title = "削除失敗回"
|
||||
newAfterConfirmation := libraryJob(3, "show/new.mp4")
|
||||
newAfterConfirmation.EpisodeID = "ep3"
|
||||
newAfterConfirmation.Title = "確認後の新着"
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": first, "ep2": blocked, "ep3": newAfterConfirmation}}
|
||||
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
||||
|
||||
response := postForm(handler, "/library/delete", url.Values{
|
||||
"id": {"1", "2", "99"},
|
||||
"series_id": {"series1"},
|
||||
})
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "削除: 1件") || !strings.Contains(response.Body.String(), "失敗: 1件") || !strings.Contains(response.Body.String(), "対象外: 1件") {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if _, ok := jobs.jobs["ep1"]; ok {
|
||||
t.Fatal("正常な回の記録が残った")
|
||||
}
|
||||
if _, ok := jobs.jobs["ep2"]; !ok {
|
||||
t.Fatal("失敗した回の記録が消えた")
|
||||
}
|
||||
if _, ok := jobs.jobs["ep3"]; !ok {
|
||||
t.Fatal("確認後に増えた回が消えた")
|
||||
}
|
||||
if strings.Contains(response.Body.String(), filepath.Join(dir, "show", "blocked")) {
|
||||
t.Fatal("内部パスが結果画面に出た")
|
||||
}
|
||||
for _, detail := range []string{"directory not empty", "show/blocked"} {
|
||||
if strings.Contains(response.Body.String(), detail) {
|
||||
t.Fatalf("エラー詳細が結果画面に出た: %q", detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkDeleteWhenVideoRootCannotOpenDeletesOnlyRecordWithoutFile(t *testing.T) {
|
||||
withFile := libraryJob(1, "show/video.mp4")
|
||||
withFile.Title = `<img src=x onerror=alert(1)>`
|
||||
withoutFile := libraryJob(2, "")
|
||||
withoutFile.EpisodeID = "ep2"
|
||||
withoutFile.Title = "記録だけの回"
|
||||
withoutFile.FilePath = nil
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": withFile, "ep2": withoutFile}}
|
||||
missingRoot := filepath.Join(t.TempDir(), "missing-video-root")
|
||||
|
||||
response := postForm(newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, missingRoot, nil),
|
||||
"/library/delete", url.Values{"id": {"1", "2"}})
|
||||
if response.Code != http.StatusOK ||
|
||||
!strings.Contains(response.Body.String(), "削除: 1件") ||
|
||||
!strings.Contains(response.Body.String(), "失敗: 1件") {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if _, ok := jobs.jobs["ep1"]; !ok {
|
||||
t.Fatal("動画ルートを開けない回の記録が消えた")
|
||||
}
|
||||
if _, ok := jobs.jobs["ep2"]; ok {
|
||||
t.Fatal("file_pathがNULLの回の記録が残った")
|
||||
}
|
||||
for _, secret := range []string{missingRoot, "no such file or directory", `<img src=x`} {
|
||||
if strings.Contains(response.Body.String(), secret) {
|
||||
t.Fatalf("内部情報または未エスケープHTMLが結果画面に出た: %q", secret)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), `<img src=x onerror=alert(1)>`) {
|
||||
t.Fatalf("失敗した回のタイトルが安全に表示されていない: %s", response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkDeleteRejectsEmptySelection(t *testing.T) {
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "show/video.mp4")}}
|
||||
response := postForm(newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, t.TempDir(), nil), "/library/delete", nil)
|
||||
if response.Code != http.StatusBadRequest || len(jobs.jobs) != 1 {
|
||||
t.Fatalf("status=%d jobs=%d", response.Code, len(jobs.jobs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryDeleteEntryFormsAndOldRoute(t *testing.T) {
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "show/video.mp4")}}
|
||||
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, t.TempDir(), nil)
|
||||
for _, path := range []string{"/library", "/library/series/series1", "/library/1"} {
|
||||
response := getPage(handler, path)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `action="/library/delete/confirm"`) {
|
||||
t.Fatalf("%s: status=%d body=%s", path, response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
if body := getPage(handler, "/library/1").Body.String(); strings.Contains(body, "hx-confirm") {
|
||||
t.Fatal("再生ページにhx-confirmが残っている")
|
||||
}
|
||||
if got := postForm(handler, "/library/1/delete", nil).Code; got != http.StatusNotFound && got != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("旧経路 status=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ main { max-width: 72rem; margin: 2rem auto; padding: 0 2rem; }
|
||||
#newer-controls select, #newer-controls button { font: inherit; padding: .35rem .6rem; }
|
||||
.episode-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); gap: 1.25rem; }
|
||||
.episode-card { border: 1px solid #d6dee4; border-radius: .5rem; overflow: hidden; }
|
||||
.library-selection { display: flex; align-items: center; gap: .4rem; padding: .6rem .8rem; }
|
||||
.library-select-grid .episode-card { display: flex; flex-direction: column; }
|
||||
.library-select-grid .episode-card > a { flex: 1; }
|
||||
.episode-card a { color: inherit; text-decoration: none; }
|
||||
.episode-card a:hover h2 { text-decoration: underline; }
|
||||
.episode-card img { display: block; width: 100%; aspect-ratio: 16 / 9; object-fit: cover; background: #e5ebef; }
|
||||
@@ -35,18 +38,24 @@ video { display: block; width: 100%; max-height: 75vh; background: #000; }
|
||||
.breadcrumbs { display: flex; flex-wrap: wrap; gap: .45rem; margin-bottom: 1rem; overflow-wrap: anywhere; }
|
||||
.library-sort { margin: 1rem 0; }
|
||||
.library-episode-list { border-top: 1px solid #d6dee4; }
|
||||
.library-episode-select-row { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; border-bottom: 1px solid #d6dee4; }
|
||||
.library-episode-row { display: grid; grid-template-columns: 8rem minmax(0, 1fr) auto auto auto; gap: .8rem; align-items: center; padding: .7rem 0; border-bottom: 1px solid #d6dee4; color: inherit; text-decoration: none; }
|
||||
.library-episode-select-row .library-episode-row { border-bottom: 0; }
|
||||
.library-episode-row:hover .library-episode-title { text-decoration: underline; }
|
||||
.library-episode-row img { display: block; width: 8rem; aspect-ratio: 16 / 9; object-fit: cover; background: #e5ebef; }
|
||||
.library-episode-title { min-width: 0; overflow-wrap: anywhere; font-weight: 600; }
|
||||
.episode-navigation { display: flex; justify-content: space-between; gap: 1rem; margin: 1rem 0; }
|
||||
.episode-navigation a, .episode-navigation span { min-width: 0; overflow-wrap: anywhere; }
|
||||
.delete-warning { border-left: .3rem solid #b42318; background: #fff1f0; padding: .8rem 1rem; }
|
||||
.delete-group { overflow-wrap: anywhere; }
|
||||
|
||||
@media (max-width: 36rem) {
|
||||
header { align-items: flex-start; flex-direction: column; gap: .75rem; padding: 1rem; }
|
||||
main { margin: 1.25rem auto; padding: 0 1rem; }
|
||||
.episode-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.library-episode-row { grid-template-columns: 6rem minmax(0, 1fr); gap: .35rem .7rem; }
|
||||
.library-episode-select-row { grid-template-columns: 1fr; }
|
||||
.library-episode-select-row > .library-selection { padding: .7rem 0 0; }
|
||||
.library-episode-row img { grid-row: 1 / span 4; width: 6rem; }
|
||||
.library-episode-row > span:not(.library-episode-title) { grid-column: 2; font-size: .9rem; }
|
||||
.episode-navigation { flex-direction: column; }
|
||||
|
||||
@@ -296,6 +296,42 @@ func (f *fakeJobStore) ListSucceededJobsBySeries(_ context.Context, seriesID str
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (f *fakeJobStore) ListSucceededJobsByIDs(_ context.Context, ids []int64) ([]store.Job, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
wanted := make(map[int64]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
wanted[id] = struct{}{}
|
||||
}
|
||||
var result []store.Job
|
||||
for _, job := range f.jobs {
|
||||
if _, ok := wanted[job.ID]; ok && job.Status == "succeeded" {
|
||||
result = append(result, job)
|
||||
}
|
||||
}
|
||||
sortJobsLatest(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (f *fakeJobStore) ListSucceededJobsBySeriesIDs(_ context.Context, seriesIDs []string) ([]store.Job, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
wanted := make(map[string]struct{}, len(seriesIDs))
|
||||
for _, id := range seriesIDs {
|
||||
wanted[id] = struct{}{}
|
||||
}
|
||||
var result []store.Job
|
||||
for _, job := range f.jobs {
|
||||
if _, ok := wanted[job.SeriesID]; ok && job.Status == "succeeded" {
|
||||
result = append(result, job)
|
||||
}
|
||||
}
|
||||
sortJobsLatest(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (f *fakeJobStore) GetSucceededJob(_ context.Context, id int64) (store.Job, error) {
|
||||
if f.err != nil {
|
||||
return store.Job{}, f.err
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
{{template "series_page" .Series}}
|
||||
{{else if .Video}}
|
||||
{{template "video_page" .Video}}
|
||||
{{else if .DeleteConfirm}}
|
||||
{{template "delete_confirm_page" .DeleteConfirm}}
|
||||
{{else if .DeleteResult}}
|
||||
{{template "delete_result_page" .DeleteResult}}
|
||||
{{else}}
|
||||
<h1>{{.Title}}</h1>
|
||||
{{end}}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
<h1>ライブラリ</h1>
|
||||
{{if .Series}}
|
||||
<p>{{.SeriesCount}}番組・{{.EpisodeCount}}本・{{.TotalSizeText}}</p>
|
||||
<div class="episode-grid">
|
||||
<form action="/library/delete/confirm" method="post">
|
||||
<div class="episode-grid library-select-grid">
|
||||
{{range .Series}}
|
||||
<article class="episode-card">
|
||||
<label class="library-selection"><input type="checkbox" name="series_id" value="{{.SeriesID}}"> この番組を選択</label>
|
||||
<a href="/library/series/{{.SeriesID}}">
|
||||
<div class="library-card-image">
|
||||
<img src="{{.ThumbnailURL}}" alt="" loading="lazy">
|
||||
@@ -21,6 +23,8 @@
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
<p><button type="submit">選択した番組を削除</button></p>
|
||||
</form>
|
||||
{{else}}
|
||||
<p>ダウンロード済みの動画はありません</p>
|
||||
{{end}}
|
||||
@@ -35,17 +39,27 @@
|
||||
/
|
||||
{{if eq .Sort "natural"}}<strong>話の順</strong>{{else}}<a href="/library/series/{{.SeriesID}}?sort=natural">話の順</a>{{end}}
|
||||
</p>
|
||||
<form action="/library/delete/confirm" method="post">
|
||||
<div class="library-episode-list">
|
||||
{{range .Items}}
|
||||
<a class="library-episode-row" href="/library/{{.ID}}">
|
||||
<img src="{{.ThumbnailURL}}" alt="" loading="lazy">
|
||||
<span class="library-episode-title">{{.Title}}</span>
|
||||
<span>{{.FinishedAtText}}</span>
|
||||
<span>{{.DurationText}}</span>
|
||||
<span>{{.FileSizeText}}</span>
|
||||
</a>
|
||||
<div class="library-episode-select-row">
|
||||
<label class="library-selection"><input type="checkbox" name="id" value="{{.ID}}"> 選択</label>
|
||||
<a class="library-episode-row" href="/library/{{.ID}}">
|
||||
<img src="{{.ThumbnailURL}}" alt="" loading="lazy">
|
||||
<span class="library-episode-title">{{.Title}}</span>
|
||||
<span>{{.FinishedAtText}}</span>
|
||||
<span>{{.DurationText}}</span>
|
||||
<span>{{.FileSizeText}}</span>
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<p><button type="submit">選択した回を削除</button></p>
|
||||
</form>
|
||||
<form action="/library/delete/confirm" method="post">
|
||||
<input type="hidden" name="series_id" value="{{.SeriesID}}">
|
||||
<button type="submit">この番組をすべて削除</button>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
{{define "video_page"}}
|
||||
@@ -58,7 +72,44 @@
|
||||
{{if .Previous}}<a href="/library/{{.Previous.ID}}">← 前の回: {{.Previous.Title}}</a>{{else}}<span aria-disabled="true">前の回はありません</span>{{end}}
|
||||
{{if .Next}}<a href="/library/{{.Next.ID}}">次の回: {{.Next.Title}} →</a>{{else}}<span aria-disabled="true">次の回はありません</span>{{end}}
|
||||
</nav>
|
||||
<form action="/library/{{.ID}}/delete" method="post" hx-confirm="動画ファイルと記録を削除しますか?">
|
||||
<form action="/library/delete/confirm" method="post">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button type="submit">削除</button>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
{{define "delete_confirm_page"}}
|
||||
<nav class="breadcrumbs" aria-label="パンくず"><a href="/library">ライブラリ</a><span aria-hidden="true">›</span><span>削除確認</span></nav>
|
||||
<h1>動画の削除確認</h1>
|
||||
<p class="delete-warning">ファイルと記録を削除します。この操作は取り消せません。</p>
|
||||
<p>{{.Count}}本・合計{{.TotalSizeText}}</p>
|
||||
{{range .Groups}}
|
||||
<section class="delete-group">
|
||||
<h2>{{.SeriesTitle}}</h2>
|
||||
<p>{{len .Items}}本・{{.TotalSizeText}}</p>
|
||||
<ul>
|
||||
{{range .Items}}<li>{{.Title}}({{.FileSizeText}})</li>{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
{{end}}
|
||||
<form action="/library/delete" method="post">
|
||||
{{range .IDs}}<input type="hidden" name="id" value="{{.}}">{{end}}
|
||||
<button type="submit">削除を実行</button>
|
||||
<a href="{{.CancelURL}}">取り消す</a>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
{{define "delete_result_page"}}
|
||||
<nav class="breadcrumbs" aria-label="パンくず"><a href="/library">ライブラリ</a><span aria-hidden="true">›</span><span>削除結果</span></nav>
|
||||
<h1>動画の削除結果</h1>
|
||||
<p>削除: {{.DeletedCount}}件/失敗: {{len .Failures}}件/対象外: {{.ExcludedCount}}件</p>
|
||||
{{if .Failures}}
|
||||
<h2>失敗した回</h2>
|
||||
<ul>{{range .Failures}}<li>{{.Title}}: {{.Reason}}</li>{{end}}</ul>
|
||||
{{end}}
|
||||
<p><a href="/library">ライブラリへ戻る</a></p>
|
||||
{{if .SeriesLinks}}
|
||||
<h2>回が残っている番組</h2>
|
||||
<ul>{{range .SeriesLinks}}<li><a href="/library/series/{{.SeriesID}}">{{.SeriesTitle}}</a></li>{{end}}</ul>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user