diff --git a/PROJECT.md b/PROJECT.md index 825495e..eb125de 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -54,7 +54,9 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ | ファイル名 | `<番組名>/<番組名> <回タイトル> [<エピソードID>].mp4`。禁止文字と制御文字を `_` に置換し、空の要素は `名称未設定`とする。番組名はUTF-8で90バイト、回タイトルは140バイト、サニタイズ後のエピソードIDは64バイトまでとし、ファイル名全体を255バイト以下に収める。64バイトを超えるIDはworkerで`invalid_output`にする。実際のTVer IDは約10文字で、yt-dlpのextractorも英数字として扱うため64バイトを上限とした。既存ファイルは改名せず、新しい保存から適用する | | 動画ファイルの境界 | webは`VIDEO_DIR`を`os.OpenRoot`で開き、DBの相対`file_path`を`Root.Open`・`Root.Remove`へ渡す。`VIDEO_DIR`外へのパストラバーサルと外向きシンボリックリンクをOSのディレクトリ境界で拒否する | | 同名ファイル | 移動先に同名ファイルがあれば上書きする。ファイル名にエピソード ID が入り `episode_id` は一意なので、同名は同じ回の前回処理の残りに限られる。再実行で同じ結果になることを優先する | -| 動画の削除 | ファイルを先に削除してからジョブ記録を削除する。ファイルが既に無い場合も記録は削除する。登録番組の `known_episodes` は残す | +| ライブラリ一覧 | DBの完了済みジョブから作り、NASのフォルダ走査やファイル存在確認は行わない。番組一覧、番組ごとの回一覧、再生の3段構成にする | +| 回の自然順 | 回タイトル中の半角・全角数字の並びを数値として比較し、同順位は完了日時の古い順、さらにジョブIDの小さい順にする | +| 動画の削除 | アプリからだけ行う。ファイルを先に削除してからジョブ記録を削除し、親の番組フォルダが完全に空なら削除する。ファイルが既に無い場合も記録は削除する。登録番組の `known_episodes` は残す | | 同時ダウンロード数 | 既定は **1**。`WORKER_CONCURRENCY` で **2まで**増やせる | | ダウンロードの再試行 | 自動では再試行しない。失敗を `failed` で残し、画面から手動で再試行する | | 実行中の取り消し | web で `running` を `canceled` にし、worker が検知して yt-dlp のプロセスグループを停止する | @@ -91,8 +93,9 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ | `web` | UI・API。新着一覧の取得、待ち行列への登録、番組URLを含む番組登録、動画の配信(再生) | | `worker` | Go + yt-dlp + ffmpeg。待ち行列からダウンロードし、登録番組の新しい回を定期的に確認する | -- 現在の `web` は `/healthz`、新着(`/`)、待ち行列(`/queue`)、登録番組(`/subscriptions`)、完了済みライブラリ(`/library`)、再生ページ(`/library/{id}`)、Range 対応の MP4 配信(`/videos/{id}`)を提供する -- 動画配信と削除はクライアントからジョブ ID だけを受け取り、DB の相対 `file_path` を `os.Root` の `Open`・`Remove` で扱う。絶対パス・`..`・`VIDEO_DIR` 外を指すシンボリックリンクをディレクトリ境界で拒否する。削除後は新着から手動で再登録できるが、登録番組の既知記録は残るため自動再登録しない +- 現在の `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` 外を指すシンボリックリンクをディレクトリ境界で拒否する。削除はアプリからだけ行い、動画削除後に実ディレクトリの親が空なら削除する。削除後は新着から手動で再登録できるが、登録番組の既知記録は残るため自動再登録しない - 新着の「番組を登録」から、公開中の回をダウンロードするか選ぶ。web はサーバー側の新着キャッシュから番組 ID と名前を引き、TVer の season ごとの公開中エピソードを取得する。取得失敗時は登録しない - `/subscriptions`では`https://tver.jp/series/`形式の番組URLを受け付け、検証した番組IDからTVer APIの接続先を組み立てる。登録前に番組名・番組ID・公開中の回数を確認し、登録時にもサーバー側で番組名と公開中の回を取り直す。クライアントから番組名は受け取らない - 登録と公開中の回の既知記録・任意の待ち行列投入は1トランザクションで行い、登録時刻を最終確認日時に設定する。`known_episodes` は番組解除時に連動して削除する。一時停止中は投入を拒否する @@ -155,6 +158,10 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ - 登録済みのDSM日次バックアップタスクが翌日以降に自動実行され、新しいダンプを作ることを確認する [要確認] - DRM・地域制限の実例探しはTVerへの大量アクセスになるため行わず、運用中に観測する。workerは失敗時の`error_message`をDBへ保存し、既知の文言に一致しない失敗は`unknown`にする。実例が出た時点で分類を見直す +## 経緯 + +- ステップ12でライブラリを番組一覧、番組ページ、再生ページの3段構成にした。番組単位の集計と登録状態はDBから取得し、番組ページの話の順と再生ページの前後移動には同じ自然順を使う。動画削除後は空になった番組フォルダだけを`os.Root`経由で削除する + ## 確かめ方 - Codex では `go test -count=1 ./...` を環境変数の前置なしで実行する。`go test` を許可する rules に一致させ、Postgres の共有メモリ作成に必要な権限で実行するため。初回のモジュール取得にはネットワーク接続が必要 @@ -164,7 +171,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 表示、登録・状態表示・待ち行列の操作を確認する -- ライブラリのテストは一時ディレクトリの小さな動画を使い、Range の 206・範囲外の 416、`os.Root`による相対パスとシンボリックリンクの拒否、404、ファイル削除後の記録削除を確認する。共有のファイル名規則は`internal/media`で90・140・255バイトの境界とUTF-8の完全性をテストする +- ライブラリのテストは番組単位の集計、番組別の回取得、3画面、半角・全角数字の自然順、前後移動を確認する。一時ディレクトリの小さな動画を使い、Rangeの206・範囲外の416、`os.Root`による相対パスとシンボリックリンクの拒否、404、ファイル削除後の記録削除、空の番組フォルダだけを削除することも確認する。共有のファイル名規則は`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 4d33b83..2dcee96 100644 --- a/internal/store/library_test.go +++ b/internal/store/library_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "testing" + "time" "github.com/jackc/pgx/v5" ) @@ -29,6 +30,70 @@ func completeTestJob(t *testing.T, s *Store, id string) Job { return result } +func TestLibrarySeriesAggregationAndJobs(t *testing.T) { + s := New(testPool(t, true)) + ctx := context.Background() + complete := func(ep Episode, size int64, finished time.Time) Job { + t.Helper() + job, _, err := s.Enqueue(ctx, ep, "manual") + if err != nil { + t.Fatal(err) + } + claimed, err := s.ClaimNext(ctx) + if err != nil || claimed.ID != job.ID { + t.Fatalf("claim: %+v %v", claimed, err) + } + if err := s.Complete(ctx, job.ID, ep.SeriesTitle+"/video.mp4", size); err != nil { + t.Fatal(err) + } + if _, err := s.pool.Exec(ctx, `UPDATE download_jobs SET finished_at=$2 WHERE id=$1`, job.ID, finished); err != nil { + t.Fatal(err) + } + result, err := s.GetSucceededJob(ctx, job.ID) + if err != nil { + t.Fatal(err) + } + return result + } + older := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + newer := older.Add(time.Hour) + newest := newer.Add(time.Hour) + first := complete(Episode{ID: "aggregate-a1", SeriesID: "seriesA", SeriesTitle: "旧番組名", Title: "第1話", BroadcasterName: "旧局", ThumbnailPath: "/old.jpg"}, 100, older) + second := complete(Episode{ID: "aggregate-a2", SeriesID: "seriesA", SeriesTitle: "新番組名", Title: "第2話", BroadcasterName: "新局", ThumbnailPath: "/new.jpg"}, 250, newer) + complete(Episode{ID: "aggregate-b1", SeriesID: "seriesB", SeriesTitle: "別番組", Title: "第1話", BroadcasterName: "別局", ThumbnailPath: "/other.jpg"}, 500, newest) + if _, err := s.pool.Exec(ctx, `UPDATE download_jobs SET file_size_bytes=NULL WHERE id=$1`, first.ID); err != nil { + t.Fatal(err) + } + if _, err := s.pool.Exec(ctx, `INSERT INTO subscriptions (series_id, series_title) VALUES ('seriesA', '登録名')`); err != nil { + t.Fatal(err) + } + + series, err := s.ListLibrarySeries(ctx) + if err != nil { + t.Fatal(err) + } + if len(series) != 2 || series[0].SeriesID != "seriesB" || series[1].SeriesID != "seriesA" { + t.Fatalf("番組の並び: %+v", series) + } + got := series[1] + if got.SeriesTitle != "新番組名" || got.LatestTitle != "第2話" || got.BroadcasterName != "新局" || got.ThumbnailPath != "/new.jpg" || got.EpisodeCount != 2 || got.TotalSizeBytes != 250 || got.LatestFinishedAt == nil || !got.LatestFinishedAt.Equal(newer) || !got.Subscribed { + t.Fatalf("番組集計: %+v", got) + } + if series[0].Subscribed || series[0].TotalSizeBytes != 500 { + t.Fatalf("未登録番組: %+v", series[0]) + } + jobs, err := s.ListSucceededJobsBySeries(ctx, "seriesA") + if err != nil || len(jobs) != 2 || jobs[0].ID != second.ID || jobs[1].ID != first.ID || jobs[1].FileSizeBytes != nil { + t.Fatalf("番組の回: %+v %v", jobs, err) + } + if jobs, err := s.ListSucceededJobsBySeries(ctx, "missing"); err != nil || len(jobs) != 0 { + t.Fatalf("存在しない番組: %+v %v", jobs, err) + } + if _, err := s.ListSucceededJobsBySeries(ctx, ""); err == nil { + t.Fatal("空の番組IDを許した") + } +} + func TestLibraryStoreOperations(t *testing.T) { s := New(testPool(t, true)) ctx := context.Background() diff --git a/internal/store/store.go b/internal/store/store.go index 3f49f3f..bc559b8 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -42,6 +42,14 @@ type Job struct { CreatedAt, UpdatedAt time.Time } +type LibrarySeries struct { + SeriesID, SeriesTitle, LatestTitle string + BroadcasterName, ThumbnailPath string + EpisodeCount, TotalSizeBytes int64 + LatestFinishedAt *time.Time + Subscribed bool +} + type Progress struct { Phase string DownloadedBytes *int64 @@ -138,6 +146,61 @@ func (s *Store) ListSucceededJobs(ctx context.Context, limit int) ([]Job, error) return jobs, rows.Err() } +func (s *Store) ListLibrarySeries(ctx context.Context) ([]LibrarySeries, error) { + rows, err := s.pool.Query(ctx, `WITH totals AS ( + SELECT series_id, count(*) AS episode_count, + COALESCE(sum(file_size_bytes), 0) AS total_size_bytes + FROM download_jobs WHERE status='succeeded' GROUP BY series_id + ), latest AS ( + SELECT DISTINCT ON (series_id) series_id, series_title, title, + broadcaster_name, thumbnail_path, finished_at, id + FROM download_jobs WHERE status='succeeded' + ORDER BY series_id, finished_at DESC NULLS LAST, id DESC + ) + SELECT l.series_id, l.series_title, l.title, l.broadcaster_name, + l.thumbnail_path, t.episode_count, t.total_size_bytes, l.finished_at, + EXISTS (SELECT 1 FROM subscriptions s WHERE s.series_id=l.series_id) + FROM latest l JOIN totals t USING (series_id) + ORDER BY l.finished_at DESC NULLS LAST, l.id DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + var result []LibrarySeries + for rows.Next() { + var item LibrarySeries + if err := rows.Scan(&item.SeriesID, &item.SeriesTitle, &item.LatestTitle, + &item.BroadcasterName, &item.ThumbnailPath, &item.EpisodeCount, + &item.TotalSizeBytes, &item.LatestFinishedAt, &item.Subscribed); err != nil { + return nil, err + } + result = append(result, item) + } + return result, rows.Err() +} + +func (s *Store) ListSucceededJobsBySeries(ctx context.Context, seriesID string) ([]Job, error) { + if seriesID == "" { + return nil, errors.New("series ID must not be empty") + } + rows, err := s.pool.Query(ctx, `SELECT `+jobColumns+` FROM download_jobs + WHERE status='succeeded' AND series_id=$1 + ORDER BY finished_at DESC NULLS LAST, id DESC`, seriesID) + 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 58538bd..ceaa36d 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -22,6 +22,7 @@ type page struct { Queue *queueView Subscriptions *subscriptionsView Library *libraryView + Series *seriesView Video *videoView } @@ -39,6 +40,8 @@ type JobStore interface { SetSubscriptionEnabled(context.Context, string, bool) error DeleteSubscription(context.Context, string) (bool, error) ListSucceededJobs(context.Context, int) ([]store.Job, error) + ListLibrarySeries(context.Context) ([]store.LibrarySeries, error) + ListSucceededJobsBySeries(context.Context, string) ([]store.Job, error) GetSucceededJob(context.Context, int64) (store.Job, error) DeleteSucceededJob(context.Context, int64) error } @@ -94,6 +97,7 @@ func NewHandler(fetcher NewerFetcher, jobs JobStore, videoDir string, now func() mux.HandleFunc("POST /subscriptions/{id}/resume", s.resumeSubscription) mux.HandleFunc("POST /subscriptions/{id}/delete", s.deleteSubscription) mux.HandleFunc("GET /library", s.libraryPage) + 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) diff --git a/internal/web/library.go b/internal/web/library.go index 4887c88..b220ec5 100644 --- a/internal/web/library.go +++ b/internal/web/library.go @@ -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) { diff --git a/internal/web/library_test.go b/internal/web/library_test.go index 192c8d4..7b5894c 100644 --- a/internal/web/library_test.go +++ b/internal/web/library_test.go @@ -16,7 +16,7 @@ import ( func libraryJob(id int64, relative string) store.Job { size := int64(10) finished := time.Date(2026, 10, 3, 1, 2, 3, 0, time.UTC) - return store.Job{ID: id, EpisodeID: "ep1", SeriesTitle: ``, Title: `第1話`, + return store.Job{ID: id, EpisodeID: "ep1", SeriesID: "series1", SeriesTitle: ``, Title: `第1話`, BroadcasterName: "放送局", DurationSeconds: 61, ThumbnailPath: "/thumb.jpg", Status: "succeeded", FilePath: &relative, FileSizeBytes: &size, FinishedAt: &finished} } @@ -30,7 +30,7 @@ func TestLibraryAndPlaybackPage(t *testing.T) { t.Fatalf("library status=%d", page.Code) } body := page.Body.String() - for _, want := range []string{"<script>番組</script>", "<b>第1話</b>", "放送局", "長さ: 2分", "10.0 B", "2026-10-03 10:02 JST", "https://statics.tver.jp/thumb.jpg", `/library/1`} { + for _, want := range []string{"<script>番組</script>", "<b>第1話</b>", "放送局", "1番組・1本・0.00 GB", "10.0 B", "2026-10-03 10:02 JST", "https://statics.tver.jp/thumb.jpg", `/library/series/series1`, "1本"} { if !strings.Contains(body, want) { t.Errorf("library missing %q", want) } @@ -39,7 +39,7 @@ 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(), `