From cb6fc1df4c2347c0c3d072ac0b452a3e0f78e018 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AD=A6=E8=94=B5=E4=B8=80=E5=BC=98?= Date: Thu, 15 Oct 2026 23:27:51 +0900 Subject: [PATCH] =?UTF-8?q?=E5=8B=95=E7=94=BB=E3=82=92=E7=A2=BA=E8=AA=8D?= =?UTF-8?q?=E7=94=BB=E9=9D=A2=E3=81=A4=E3=81=8D=E3=81=A7=E8=A4=87=E6=95=B0?= =?UTF-8?q?=E5=89=8A=E9=99=A4=E3=81=A7=E3=81=8D=E3=82=8B=E3=82=88=E3=81=86?= =?UTF-8?q?=E3=81=AB=E3=81=99=E3=82=8B=EF=BC=88=E3=82=B9=E3=83=86=E3=83=83?= =?UTF-8?q?=E3=83=9713=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT.md | 9 +- internal/store/library_test.go | 42 +++++ internal/store/store.go | 33 ++++ internal/web/handler.go | 7 +- internal/web/library.go | 224 +++++++++++++++++++++++--- internal/web/library_test.go | 238 +++++++++++++++++++++++++--- internal/web/static/app.css | 9 ++ internal/web/store_test.go | 36 +++++ internal/web/templates/layout.html | 4 + internal/web/templates/library.html | 69 ++++++-- 10 files changed, 609 insertions(+), 62 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index eb125de..d939802 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -13,7 +13,7 @@ status: 本番稼働中 ## 今の状態 -- Go の HTTP サーバー、TVer API クライアント、ドラマ・バラエティの新着表示、テスト、web・worker コンテナの定義がある +- Go の HTTP サーバー、TVer API クライアント、ドラマ・バラエティの新着表示、確認画面つきの動画複数削除、テスト、web・worker コンテナの定義がある - **本番稼働中。** dockageのweb・worker、NASのPostgreSQL 17、NFS動画保存を構成し、実動画の取得・再生・Range配信まで確認済み - リポジトリ: `https://gitea.kazu634.com/kazu634/tver.git` @@ -56,7 +56,7 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ | 同名ファイル | 移動先に同名ファイルがあれば上書きする。ファイル名にエピソード ID が入り `episode_id` は一意なので、同名は同じ回の前回処理の残りに限られる。再実行で同じ結果になることを優先する | | ライブラリ一覧 | DBの完了済みジョブから作り、NASのフォルダ走査やファイル存在確認は行わない。番組一覧、番組ごとの回一覧、再生の3段構成にする | | 回の自然順 | 回タイトル中の半角・全角数字の並びを数値として比較し、同順位は完了日時の古い順、さらにジョブIDの小さい順にする | -| 動画の削除 | アプリからだけ行う。ファイルを先に削除してからジョブ記録を削除し、親の番組フォルダが完全に空なら削除する。ファイルが既に無い場合も記録は削除する。登録番組の `known_episodes` は残す | +| 動画の削除 | アプリからだけ行い、削除前にサーバーが描画する確認画面を必ず挟む。ライブラリでは番組単位、番組ページでは選択した回または番組全体を複数削除できる。確認画面で確定したジョブ ID だけを実行するため、確認後に増えた回は削除しない。各回はファイルを先に削除してからジョブ記録を削除し、親の番組フォルダが完全に空なら削除する。ファイルが既に無い場合も記録は削除し、1件の失敗で残りを止めない。登録番組の `known_episodes` は残す | | 同時ダウンロード数 | 既定は **1**。`WORKER_CONCURRENCY` で **2まで**増やせる | | ダウンロードの再試行 | 自動では再試行しない。失敗を `failed` で残し、画面から手動で再試行する | | 実行中の取り消し | web で `running` を `canceled` にし、worker が検知して yt-dlp のプロセスグループを停止する | @@ -95,7 +95,7 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ - 現在の `web` は `/healthz`、新着(`/`)、待ち行列(`/queue`)、登録番組(`/subscriptions`)、番組単位のライブラリ(`/library`)、番組ごとの回一覧(`/library/series/{series_id}`)、再生ページ(`/library/{id}`)、Range 対応の MP4 配信(`/videos/{id}`)を提供する - ライブラリはDBの完了済みジョブだけを基に、番組一覧から番組ページ、再生ページへ進む。番組ページは完了が新しい順と、回タイトルの半角・全角数字を数値比較する話の順を切り替えられる。再生ページは話の順で前後の回へ移動できる -- 動画配信と削除はクライアントからジョブ ID だけを受け取り、DB の相対 `file_path` を `os.Root` の `Open`・`Remove` で扱う。絶対パス・`..`・`VIDEO_DIR` 外を指すシンボリックリンクをディレクトリ境界で拒否する。削除はアプリからだけ行い、動画削除後に実ディレクトリの親が空なら削除する。削除後は新着から手動で再登録できるが、登録番組の既知記録は残るため自動再登録しない +- 動画配信はクライアントからジョブ ID だけを受け取り、DB の相対 `file_path` を `os.Root` の `Open`・`Remove` で扱う。絶対パス・`..`・`VIDEO_DIR` 外を指すシンボリックリンクをディレクトリ境界で拒否する。削除はライブラリまたは番組ページの選択、もしくは再生ページの1件削除からサーバー描画の確認画面へ進み、解決済みのジョブ ID を実行画面へ引き継ぐ。実行時は1リクエストにつき動画ルートを1回だけ開き、各回を独立して処理して削除・失敗・対象外の件数を結果画面へ表示する。動画削除後に実ディレクトリの親が空なら削除する。削除後は新着から手動で再登録できるが、登録番組の既知記録は残るため自動再登録しない - 新着の「番組を登録」から、公開中の回をダウンロードするか選ぶ。web はサーバー側の新着キャッシュから番組 ID と名前を引き、TVer の season ごとの公開中エピソードを取得する。取得失敗時は登録しない - `/subscriptions`では`https://tver.jp/series/`形式の番組URLを受け付け、検証した番組IDからTVer APIの接続先を組み立てる。登録前に番組名・番組ID・公開中の回数を確認し、登録時にもサーバー側で番組名と公開中の回を取り直す。クライアントから番組名は受け取らない - 登録と公開中の回の既知記録・任意の待ち行列投入は1トランザクションで行い、登録時刻を最終確認日時に設定する。`known_episodes` は番組解除時に連動して削除する。一時停止中は投入を拒否する @@ -160,6 +160,7 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ ## 経緯 +- ステップ13で、ライブラリの番組単位削除、番組ページの回選択・番組全体削除、再生ページの1件削除を、すべてサーバー描画の確認画面を経由する方式に統一した。確認時に解決した最大1,000件のジョブ ID だけを実行し、途中の失敗後も残りを処理して結果を表示する - ステップ12でライブラリを番組一覧、番組ページ、再生ページの3段構成にした。番組単位の集計と登録状態はDBから取得し、番組ページの話の順と再生ページの前後移動には同じ自然順を使う。動画削除後は空になった番組フォルダだけを`os.Root`経由で削除する ## 確かめ方 @@ -171,7 +172,7 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ - 番組一覧の応答は `internal/tver/testdata/series_seasons.json` と `season_episodes.json`(2026-10-03 の実応答を各1件に縮小)に記録した。テストは `httptest` を使い、実 API にアクセスしない - 番組情報の応答は`internal/tver/testdata/series.json`(2026-10-07の実応答を番組情報だけに縮小)に記録した。番組URLは純関数でscheme・host・path・IDを検証し、TVerクライアントのテストは`httptest`だけへ接続する - 新着画面のテストは偽物の TVer クライアント・store と差し替え可能な時計を使い、表示項目、絞り込みの境界、10 分キャッシュ、取得失敗、JST 表示、登録・状態表示・待ち行列の操作を確認する -- ライブラリのテストは番組単位の集計、番組別の回取得、3画面、半角・全角数字の自然順、前後移動を確認する。一時ディレクトリの小さな動画を使い、Rangeの206・範囲外の416、`os.Root`による相対パスとシンボリックリンクの拒否、404、ファイル削除後の記録削除、空の番組フォルダだけを削除することも確認する。共有のファイル名規則は`internal/media`で90・140・255バイトの境界とUTF-8の完全性をテストする +- ライブラリのテストは番組単位の集計、番組別・ID指定の完了済み回取得、3画面、半角・全角数字の自然順、前後移動を確認する。一時ディレクトリの小さな動画を使い、Rangeの206・範囲外の416、`os.Root`による相対パスとシンボリックリンクの拒否、404、サーバー確認画面の入力検証・重複除去・HTMLエスケープ、複数削除の部分失敗・対象外・確認後の新着保持、ファイル削除後の記録削除、空の番組フォルダだけを削除することも確認する。共有のファイル名規則は`internal/media`で90・140・255バイトの境界とUTF-8の完全性をテストする - worker のテストは `internal/worker/testdata` の偽 yt-dlp・ffprobe を使い、本物の TVer へ接続せずに正常完了、失敗分類、取消、終了時の待機復帰、進捗の間引き、heartbeat、出力検査、移動先、作業場所の片付けを確認する - 登録番組の定期確認は偽の TVer クライアントと差し替え可能な時計・タイマーを使い、24時間の境界、順次確認、1時間走査、失敗記録、予告の既知化、終了時の中断を確認する。store の抽出・既知化・投入・日時更新・エラー消去は embedded Postgres 17 で確認する - NAS用Composeは`deploy/nas/tver-db/compose.yaml`をYAMLパーサーで読み、`services.postgres`、環境変数、ポート、保存先、healthcheckを確認する。Dockerが使える環境では`docker compose --env-file deploy/nas/tver-db/.env.example -f deploy/nas/tver-db/compose.yaml config`でも展開結果を確認する diff --git a/internal/store/library_test.go b/internal/store/library_test.go index 2dcee96..21e8150 100644 --- a/internal/store/library_test.go +++ b/internal/store/library_test.go @@ -161,3 +161,45 @@ func TestDeleteSucceededJobKeepsKnownEpisodeAndAllowsManualRequeue(t *testing.T) t.Fatalf("削除後の手動再投入: %+v %t %v", requeued, created, err) } } + +func TestListSucceededJobsByIDsAndSeriesIDs(t *testing.T) { + s := New(testPool(t, true)) + ctx := context.Background() + first := completeTestJob(t, s, "bulk-list-1") + + ep := seriesEpisode("bulk-list-2", "series-2") + second, _, err := s.Enqueue(ctx, ep, "manual") + if err != nil { + t.Fatal(err) + } + claimed, err := s.ClaimNext(ctx) + if err != nil || claimed.ID != second.ID { + t.Fatalf("claim: %+v %v", claimed, err) + } + if err := s.Complete(ctx, second.ID, "別番組/video.mp4", 5678); err != nil { + t.Fatal(err) + } + second, err = s.GetSucceededJob(ctx, second.ID) + if err != nil { + t.Fatal(err) + } + queued, _, err := s.Enqueue(ctx, seriesEpisode("bulk-list-queued", "series-2"), "manual") + if err != nil { + t.Fatal(err) + } + + byIDs, err := s.ListSucceededJobsByIDs(ctx, []int64{second.ID, queued.ID, first.ID}) + if err != nil || len(byIDs) != 2 || byIDs[0].ID != first.ID || byIDs[1].ID != second.ID { + t.Fatalf("ID指定の完了済み取得: %+v %v", byIDs, err) + } + bySeries, err := s.ListSucceededJobsBySeriesIDs(ctx, []string{"series-2", "series-1"}) + if err != nil || len(bySeries) != 2 || bySeries[0].ID != first.ID || bySeries[1].ID != second.ID { + t.Fatalf("番組指定の完了済み取得: %+v %v", bySeries, err) + } + if empty, err := s.ListSucceededJobsByIDs(ctx, nil); err != nil || len(empty) != 0 { + t.Fatalf("空のID指定: %+v %v", empty, err) + } + if empty, err := s.ListSucceededJobsBySeriesIDs(ctx, []string{}); err != nil || len(empty) != 0 { + t.Fatalf("空の番組指定: %+v %v", empty, err) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index bc559b8..bed229f 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -201,6 +201,39 @@ func (s *Store) ListSucceededJobsBySeries(ctx context.Context, seriesID string) return jobs, rows.Err() } +func (s *Store) ListSucceededJobsByIDs(ctx context.Context, ids []int64) ([]Job, error) { + if len(ids) == 0 { + return []Job{}, nil + } + return s.listSucceededJobs(ctx, `id = ANY($1)`, ids) +} + +func (s *Store) ListSucceededJobsBySeriesIDs(ctx context.Context, seriesIDs []string) ([]Job, error) { + if len(seriesIDs) == 0 { + return []Job{}, nil + } + return s.listSucceededJobs(ctx, `series_id = ANY($1)`, seriesIDs) +} + +func (s *Store) listSucceededJobs(ctx context.Context, condition string, value any) ([]Job, error) { + rows, err := s.pool.Query(ctx, `SELECT `+jobColumns+` FROM download_jobs + WHERE status='succeeded' AND `+condition+` + ORDER BY id`, value) + if err != nil { + return nil, err + } + defer rows.Close() + var jobs []Job + for rows.Next() { + job, err := scanJob(rows) + if err != nil { + return nil, err + } + jobs = append(jobs, job) + } + return jobs, rows.Err() +} + func (s *Store) DeleteSucceededJob(ctx context.Context, id int64) error { tag, err := s.pool.Exec(ctx, `DELETE FROM download_jobs WHERE id=$1 AND status='succeeded'`, id) return transitionResult(tag.RowsAffected(), err) diff --git a/internal/web/handler.go b/internal/web/handler.go index ceaa36d..54b0a16 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -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 } diff --git a/internal/web/library.go b/internal/web/library.go index b220ec5..e7d157c 100644 --- a/internal/web/library.go +++ b/internal/web/library.go @@ -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) { diff --git a/internal/web/library_test.go b/internal/web/library_test.go index 7b5894c..1af505c 100644 --- a/internal/web/library_test.go +++ b/internal/web/library_test.go @@ -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(), `