diff --git a/.env.example b/.env.example index ce81892..c34ef25 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,3 @@ DATABASE_URL= +VIDEO_HOST_DIR= +VIDEO_DIR=/videos diff --git a/PROJECT.md b/PROJECT.md index 36f05b4..9bb3a4a 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -3,7 +3,7 @@ title: tver の現状 tags: - homelab - tver -status: 番組登録の画面を実装中 +status: ライブラリ・再生・削除を実装中 --- # tver の現状 @@ -14,7 +14,7 @@ status: 番組登録の画面を実装中 ## 今の状態 - Go の HTTP サーバー、TVer API クライアント、ドラマ・バラエティの新着表示、テスト、web コンテナの定義がある -- **番組登録の画面を実装中。** 新着から番組を登録し、公開中の回をその場で待ち行列に入れるか選べる。一時停止・再開・解除と登録番組一覧を提供する。worker がまだ無いため、登録済み番組の定期確認とダウンロード実行は行わない +- **ライブラリ・再生・削除を実装中。** 完了したジョブを一覧し、ブラウザで Range 対応の再生とファイル・ジョブ記録の削除ができる。worker がまだ無いため、実際のダウンロード実行は行わない - リポジトリ: `https://gitea.kazu634.com/kazu634/tver.git` ## 目的 @@ -43,6 +43,9 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ | 定期確認の間隔 | 登録番組の新しい回を **24 時間ごと**に確認する | | 登録時の既存回 | **登録の都度**「公開中の回もダウンロードする」かを選ぶ。選ばない場合も公開中の回を既知として記録する | | 番組の一時停止 | 登録は残し、自動ダウンロードだけ止める。再開後は停止中に公開された未確認の回も対象にする | +| 動画のマウント先 | web と worker は `VIDEO_DIR`(既定 `/videos`)に同じ NAS 共有をマウントする。`download_jobs.file_path` は `VIDEO_DIR` からの相対パスを保存する | +| ファイル名 | `<番組名>/<番組名> <回タイトル> [<エピソードID>].mp4`。禁止文字と制御文字を `_` に置換し、空の要素は `名称未設定`、各入力要素は UTF-8 で60バイトまでに切り詰める。エピソード ID で同名衝突を避ける | +| 動画の削除 | ファイルを先に削除してからジョブ記録を削除する。ファイルが既に無い場合も記録は削除する。登録番組の `known_episodes` は残す | ## 要件 @@ -75,7 +78,8 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ | `web` | UI・API。新着一覧の取得、待ち行列への登録、番組登録、動画の配信(再生) | | `worker` | Go + yt-dlp + ffmpeg。待ち行列からダウンロードし、登録番組の新しい回を定期的に確認する | -- 現在の `web` は `/healthz`、ドラマ・バラエティの新着ページ(`/`)、新着一覧の HTML 断片(`/newer`)、待ち行列ページ(`/queue`)と更新用断片(`/queue/list`)、登録番組ページ(`/subscriptions`)を提供する。「ライブラリ」のページは空のまま +- 現在の `web` は `/healthz`、新着(`/`)、待ち行列(`/queue`)、登録番組(`/subscriptions`)、完了済みライブラリ(`/library`)、再生ページ(`/library/{id}`)、Range 対応の MP4 配信(`/videos/{id}`)を提供する +- 動画配信と削除はクライアントからジョブ ID だけを受け取り、DB の相対 `file_path` を使う。絶対パス・`..`・`VIDEO_DIR` 外を指すシンボリックリンクを拒否する。削除後は新着から手動で再登録できるが、登録番組の既知記録は残るため自動再登録しない - 新着の「番組を登録」から、公開中の回をダウンロードするか選ぶ。web はサーバー側の新着キャッシュから番組 ID と名前を引き、TVer の season ごとの公開中エピソードを取得する。取得失敗時は登録しない - 登録と公開中の回の既知記録・任意の待ち行列投入は1トランザクションで行い、登録時刻を最終確認日時に設定する。`known_episodes` は番組解除時に連動して削除する。worker 用の未確認回の抽出と、未確認回だけの既知記録・投入も `internal/store` に用意した。一時停止中は投入を拒否する - 新着の登録ボタンはエピソード ID とジャンルだけを送る。web が有効な新着キャッシュから番組情報を引き、`episode_id` の一意制約で重複登録を防ぐ。新着一覧はジョブ状態を一括取得し、失敗・取り消しには再試行を表示する @@ -83,11 +87,11 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ - 新着はジャンルごとにメモリへ 10 分間キャッシュする。キャッシュ中は TVer セッションの再発行と一覧の再取得を行わない。TVer の取得に失敗した場合、一覧の場所にエラーを表示して詳細をログへ記録する - 「予告を隠す」は既定でオンとし、180 秒以下の件を非表示にする。長さは秒から分へ切り上げて表示し、配信終了日時は Asia/Tokyo で表示する。ジャンルと予告表示の状態は URL に残す - 画面は `html/template` と htmx 2.0.11 を使い、htmx は Go の `embed` から `/static/` でローカル配信する -- `compose.yaml` は web のみを定義し、dockage VM の LAN アドレス `192.168.10.155:8080` で公開する。待ち受けは `WEB_ADDR` で変更でき、既定は `:8080`。DB 接続先は必須の `DATABASE_URL` を `.env` から渡す(値の無い雛形は `.env.example`) +- `compose.yaml` は web のみを定義し、dockage VM の LAN アドレス `192.168.10.155:8080` で公開する。待ち受けは `WEB_ADDR` で変更でき、既定は `:8080`。DB 接続先は `DATABASE_URL`、NAS のホスト側マウント先は `VIDEO_HOST_DIR`、コンテナ内の保存先は `VIDEO_DIR`(既定 `/videos`)として `.env` から渡す(値の無い雛形は `.env.example`) - DB は NAS 上に tver 専用 Postgres 17 コンテナを置く。`internal/store` の埋め込み SQL を `schema_migrations` で管理し、`pg_advisory_lock` で複数プロセスからの同時適用を直列化する。web は起動時に `pgxpool` で接続してマイグレーションを適用する - `download_jobs` は `episode_id` を一意にして二重登録を防ぎ、ジョブの状態・段階別進捗・結果を持つ。`subscriptions` はシリーズ単位の登録と最終確認日時を持つ。worker からの操作と画面用の一覧取得を `internal/store` にまとめる - worker は `yt-dlp[default]` と ffmpeg を外部コマンドとして使い、映像・音声・結合・移動の段階別進捗を扱う。正確な全体パーセントは保存しない。`after_move:filepath` で実パスを取り、ffprobe で H.264 と AAC の MP4 を確認して成功とする。失敗は DRM、地域制限、ネットワーク、配信不可、抽出失敗、出力不正、その他の順に分類する -- 保存先の NAS 共有フォルダは dockage VM に NFS でマウントし、`worker`(書き込み)と `web`(読み取り・削除)にバインドマウントする +- 保存先の NAS 共有フォルダは dockage VM に NFS でマウントし、`worker`(書き込み)と `web`(読み取り・削除)に同じ `VIDEO_DIR` でバインドマウントする ## 外部とのつながり @@ -124,10 +128,11 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ ## 確かめ方 - Codex では `go test -count=1 ./...` を環境変数の前置なしで実行する。`go test` を許可する rules に一致させ、Postgres の共有メモリ作成に必要な権限で実行するため。初回のモジュール取得にはネットワーク接続が必要 -- web を起動するには `.env.example` を参考に、Git 管理外の `.env` に実際の `DATABASE_URL` を設定する。未設定なら起動時にエラー終了する。DB 接続がある環境では起動時にマイグレーションが適用され、`/queue` で待ち行列を確認できる +- web を起動するには `.env.example` を参考に、Git 管理外の `.env` に実際の `DATABASE_URL` と `VIDEO_HOST_DIR` を設定する。`VIDEO_DIR` の既定は `/videos`。DB 接続がある環境では起動時にマイグレーションが適用される - DB テストは `embedded-postgres` v1.34.0 が Postgres 17 を一度起動し、テストごとに独立した DB を作る。バイナリキャッシュ、実行ファイル、データは `/private/tmp` 配下に置く。Apple Silicon での実行には Rosetta 2 の導入が必要 - TVer 応答のテストは `internal/tver/testdata/newer_drama.json`(2026-10-03 の実応答を 3 件に縮小)と `httptest` を使い、テストから本物の TVer にはアクセスしない - 番組一覧の応答は `internal/tver/testdata/series_seasons.json` と `season_episodes.json`(2026-10-03 の実応答を各1件に縮小)に記録した。テストは `httptest` を使い、実 API にアクセスしない - 新着画面のテストは偽物の TVer クライアント・store と差し替え可能な時計を使い、表示項目、絞り込みの境界、10 分キャッシュ、取得失敗、JST 表示、登録・状態表示・待ち行列の操作を確認する +- ライブラリのテストは一時ディレクトリの小さな動画を使い、Range の 206・範囲外の 416、相対パスとシンボリックリンクの検査、404、ファイル削除後の記録削除を確認する。共有のファイル名規則は `internal/media` でテストする - `env GOPATH=/private/tmp/tver-gopath GOCACHE=/private/tmp/tver-go-cache GOMODCACHE=/private/tmp/tver-go-modcache GOPROXY=off go vet ./...` で静的解析する -- `DATABASE_URL` と `WEB_ADDR=127.0.0.1:18080` を設定して `go run ./cmd/web` で起動し、`/healthz`、`/`、`/newer?genre=variety`、`/queue`、`/subscriptions` を確認できる。TVer への実アクセスを伴うため手動確認は少数回に留める +- `DATABASE_URL`、`VIDEO_DIR`、`WEB_ADDR=127.0.0.1:18080` を設定して `go run ./cmd/web` で起動し、`/healthz`、`/`、`/queue`、`/subscriptions`、`/library` を確認できる。TVer への実アクセスを伴うため手動確認は少数回に留める diff --git a/cmd/web/main.go b/cmd/web/main.go index 7297bee..2b466d4 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -31,9 +31,13 @@ func main() { if err != nil { log.Fatal(err) } + videoDir := os.Getenv("VIDEO_DIR") + if videoDir == "" { + videoDir = "/videos" + } server := &http.Server{ Addr: address, - Handler: web.NewHandler(client, store.New(pool), time.Now, log.Default()), + Handler: web.NewHandler(client, store.New(pool), videoDir, time.Now, log.Default()), ReadHeaderTimeout: 5 * time.Second, } log.Printf("web listening on %s", address) diff --git a/compose.yaml b/compose.yaml index d8255ae..5fa778b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -5,6 +5,9 @@ services: environment: TZ: Asia/Tokyo WEB_ADDR: ":8080" + VIDEO_DIR: "${VIDEO_DIR:-/videos}" + volumes: + - "${VIDEO_HOST_DIR:?VIDEO_HOST_DIR must be set}:${VIDEO_DIR:-/videos}" ports: - "192.168.10.155:8080:8080" restart: unless-stopped diff --git a/internal/media/path.go b/internal/media/path.go new file mode 100644 index 0000000..fc5315b --- /dev/null +++ b/internal/media/path.go @@ -0,0 +1,61 @@ +package media + +import ( + "path/filepath" + "strings" + "unicode" +) + +const maxComponentRunes = 80 +const maxComponentBytes = 60 + +// RelativeVideoPath は worker と web が共有する保存先の相対パスを返す。 +func RelativeVideoPath(seriesTitle, episodeTitle, episodeID string) string { + series := sanitizeComponent(seriesTitle) + episode := sanitizeComponent(episodeTitle) + id := sanitizeComponent(episodeID) + return filepath.Join(series, series+" "+episode+" ["+id+"].mp4") +} + +func sanitizeComponent(value string) string { + var result []rune + lastReplacement := false + for _, r := range value { + invalid := unicode.IsControl(r) || strings.ContainsRune(`<>:"/\|?*`, r) + if invalid { + if !lastReplacement { + result = append(result, '_') + lastReplacement = true + } + continue + } + if unicode.IsSpace(r) { + r = ' ' + } + result = append(result, r) + lastReplacement = false + } + clean := strings.Trim(string(result), " .") + if clean == "" || clean == "." || clean == ".." { + return "名称未設定" + } + runes := []rune(clean) + if len(runes) > maxComponentRunes { + runes = runes[:maxComponentRunes] + } + var limited []rune + bytes := 0 + for _, r := range runes { + size := len(string(r)) + if bytes+size > maxComponentBytes { + break + } + limited = append(limited, r) + bytes += size + } + clean = strings.Trim(string(limited), " .") + if clean == "" || clean == "." || clean == ".." { + return "名称未設定" + } + return clean +} diff --git a/internal/media/path_test.go b/internal/media/path_test.go new file mode 100644 index 0000000..648eeea --- /dev/null +++ b/internal/media/path_test.go @@ -0,0 +1,154 @@ +package media + +import ( + "path/filepath" + "strings" + "testing" + "unicode" + "unicode/utf8" +) + +func TestRelativeVideoPath(t *testing.T) { + got := RelativeVideoPath("番組名", "第1話", "ep123") + want := filepath.Join("番組名", "番組名 第1話 [ep123].mp4") + if got != want { + t.Fatalf("path=%q want=%q", got, want) + } + other := RelativeVideoPath("番組名", "第1話", "ep456") + if got == other || !strings.Contains(other, "[ep456]") { + t.Fatalf("ID による衝突回避: %q %q", got, other) + } +} + +func TestRelativeVideoPathSanitizesComponents(t *testing.T) { + got := RelativeVideoPath(" 番組:/\\*?<>|\"\x00\n\t\x7f\u0085. ", "\x00\n", "../") + if filepath.IsAbs(got) || strings.ContainsAny(got, `:*?<>|"`) || strings.Contains(got, "..") { + t.Fatalf("禁止文字が残った: %q", got) + } + for _, r := range got { + if unicode.IsControl(r) { + t.Errorf("制御文字が残った: %U in %q", r, got) + } + } + parts := strings.Split(filepath.ToSlash(got), "/") + if len(parts) != 2 || parts[0] == "" || !strings.HasSuffix(got, ".mp4") { + t.Fatalf("相対パスの形: %q", got) + } +} + +func TestRelativeVideoPathHandlesEmptyAndLongNames(t *testing.T) { + for _, name := range []string{"", " ", ".", "..", "..."} { + got := RelativeVideoPath(name, name, "") + if !strings.Contains(got, "名称未設定") { + t.Errorf("空の代替名 %q: %q", name, got) + } + } + long := strings.Repeat("界", 200) + got := RelativeVideoPath(long, long, "id") + parts := strings.Split(filepath.ToSlash(got), "/") + if len(parts) != 2 || len([]byte(parts[0])) != 60 || !utf8.ValidString(parts[0]) { + t.Fatalf("番組名の60バイト境界: %q (%d bytes)", parts[0], len([]byte(parts[0]))) + } + for _, part := range strings.Split(filepath.ToSlash(got), "/") { + if len([]byte(part)) > 240 { + t.Fatalf("長すぎる要素: %d bytes", len([]byte(part))) + } + if !utf8.ValidString(part) { + t.Fatalf("不正な UTF-8: %q", part) + } + } + if got := sanitizeComponent(strings.Repeat("a", 61)); len([]byte(got)) != 60 { + t.Fatalf("ASCII の60バイト境界: %d %q", len([]byte(got)), got) + } +} + +func TestSanitizeComponentTrimsWhitespaceBeforeLengthLimit(t *testing.T) { + for _, tt := range []struct { + name, input, want string + }{ + {"前後の空白", " \t 番組名\n ", "_ 番組名_"}, + {"空白のみ", "   ", "名称未設定"}, + {"制御文字のみ", "\t\n", "_"}, + {"多数の先頭空白", strings.Repeat(" ", maxComponentRunes+10) + "番組名", "番組名"}, + {"先頭空白と長い名前", strings.Repeat(" ", 70) + strings.Repeat("a", 70), strings.Repeat("a", 60)}, + } { + t.Run(tt.name, func(t *testing.T) { + if got := sanitizeComponent(tt.input); got != tt.want { + t.Errorf("sanitizeComponent()=%q want=%q", got, tt.want) + } + }) + } +} + +func TestSanitizeComponentTrimsAgainAfterLengthLimit(t *testing.T) { + for _, tt := range []struct { + name, input, want string + }{ + {"60バイト目が空白", strings.Repeat("a", 59) + " " + "b", strings.Repeat("a", 59)}, + {"60バイト目がドット", strings.Repeat("a", 59) + "." + "b", strings.Repeat("a", 59)}, + {"切り詰め部分が空白のみ", "a" + strings.Repeat(" ", 59) + "b", "a"}, + {"切り詰め部分がドットのみ", "a" + strings.Repeat(".", 59) + "b", "a"}, + {"複数バイト文字の直後がドット", strings.Repeat("界", 19) + ".続", strings.Repeat("界", 19)}, + } { + t.Run(tt.name, func(t *testing.T) { + got := sanitizeComponent(tt.input) + if got != tt.want { + t.Errorf("sanitizeComponent()=%q want=%q", got, tt.want) + } + if strings.Trim(got, " .") != got || got == "" { + t.Errorf("切り詰め後の名前が不正: %q", got) + } + }) + } +} + +func TestSanitizeComponentSafetyProperties(t *testing.T) { + var controls strings.Builder + for r := rune(0); r <= 0x9f; r++ { + if unicode.IsControl(r) { + controls.WriteRune(r) + } + } + inputs := []string{ + "", " ", ".", "..", "...", `<>:"/\|?*`, controls.String(), + " \t 番組名\n. ", "a/b\\c", strings.Repeat("a", 500), + strings.Repeat("界", 500), strings.Repeat("😀", 500), + strings.Repeat(".", 100) + "name" + strings.Repeat(" ", 100), + string([]byte{0xff, 0xfe, 'a', 0xc0, 0xaf}), + } + for i, input := range inputs { + got := sanitizeComponent(input) + if got == "" || got == "." || got == ".." { + t.Errorf("case %d: 空またはdot segment: %q", i, got) + } + if strings.Trim(got, " .") != got { + t.Errorf("case %d: 前後に空白またはdot: %q", i, got) + } + if len([]byte(got)) > maxComponentBytes || utf8.RuneCountInString(got) > maxComponentRunes { + t.Errorf("case %d: 上限超過: bytes=%d runes=%d %q", i, len([]byte(got)), utf8.RuneCountInString(got), got) + } + if !utf8.ValidString(got) { + t.Errorf("case %d: 不正なUTF-8: %q", i, got) + } + if strings.ContainsAny(got, `<>:"/\|?*`) { + t.Errorf("case %d: 禁止文字が残った: %q", i, got) + } + for _, r := range got { + if unicode.IsControl(r) { + t.Errorf("case %d: 制御文字が残った: %U in %q", i, r, got) + } + } + if again := sanitizeComponent(got); again != got { + t.Errorf("case %d: 再適用で変化: %q -> %q", i, got, again) + } + + path := RelativeVideoPath(input, input, input) + parts := strings.Split(filepath.ToSlash(path), "/") + if filepath.IsAbs(path) || len(parts) != 2 || parts[0] == "" || parts[0] == "." || parts[0] == ".." || !strings.HasSuffix(parts[1], ".mp4") { + t.Errorf("case %d: 安全でない相対パス: %q parts=%q", i, path, parts) + } + if !utf8.ValidString(path) { + t.Errorf("case %d: パスが不正なUTF-8: %q", i, path) + } + } +} diff --git a/internal/store/library_test.go b/internal/store/library_test.go new file mode 100644 index 0000000..4d33b83 --- /dev/null +++ b/internal/store/library_test.go @@ -0,0 +1,98 @@ +package store + +import ( + "context" + "errors" + "testing" + + "github.com/jackc/pgx/v5" +) + +func completeTestJob(t *testing.T, s *Store, id string) Job { + t.Helper() + ctx := context.Background() + job, _, err := s.Enqueue(ctx, sampleEpisode(id), "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, "番組/video.mp4", 1234); err != nil { + t.Fatal(err) + } + result, err := s.GetSucceededJob(ctx, job.ID) + if err != nil { + t.Fatal(err) + } + return result +} + +func TestLibraryStoreOperations(t *testing.T) { + s := New(testPool(t, true)) + ctx := context.Background() + first := completeTestJob(t, s, "library-1") + second := completeTestJob(t, s, "library-2") + jobs, err := s.ListSucceededJobs(ctx, 1) + if err != nil || len(jobs) != 1 || jobs[0].ID != second.ID { + t.Fatalf("完了一覧: %+v %v", jobs, err) + } + if first.FilePath == nil || *first.FilePath != "番組/video.mp4" || first.FileSizeBytes == nil || *first.FileSizeBytes != 1234 || first.FinishedAt == nil { + t.Fatalf("完了情報: %+v", first) + } + queued, _, err := s.Enqueue(ctx, sampleEpisode("not-finished"), "manual") + if err != nil { + t.Fatal(err) + } + if _, err := s.GetSucceededJob(ctx, queued.ID); !errors.Is(err, pgx.ErrNoRows) { + t.Fatalf("未完了の取得: %v", err) + } + if err := s.DeleteSucceededJob(ctx, queued.ID); !errors.Is(err, ErrInvalidTransition) { + t.Fatalf("未完了の削除: %v", err) + } + if err := s.DeleteSucceededJob(ctx, first.ID); err != nil { + t.Fatal(err) + } + if _, err := s.GetJob(ctx, first.ID); !errors.Is(err, pgx.ErrNoRows) { + t.Fatalf("削除後の取得: %v", err) + } + if err := s.DeleteSucceededJob(ctx, first.ID); !errors.Is(err, ErrInvalidTransition) { + t.Fatalf("再削除: %v", err) + } + if _, err := s.ListSucceededJobs(ctx, 0); err == nil { + t.Fatal("0件の上限を許した") + } +} + +func TestDeleteSucceededJobKeepsKnownEpisodeAndAllowsManualRequeue(t *testing.T) { + s := New(testPool(t, true)) + ctx := context.Background() + ep := seriesEpisode("known-library", "known-series") + created, err := s.RegisterSubscription(ctx, "known-series", "番組", []Episode{ep}, false) + if err != nil || !created { + t.Fatalf("番組登録: %t %v", created, err) + } + job, created, err := s.Enqueue(ctx, ep, "manual") + if err != nil || !created { + t.Fatalf("手動投入: %+v %t %v", job, created, err) + } + claimed, err := s.ClaimNext(ctx) + if err != nil || claimed.ID != job.ID { + t.Fatalf("取得: %+v %v", claimed, err) + } + if err := s.Complete(ctx, job.ID, "番組/video.mp4", 10); err != nil { + t.Fatal(err) + } + if err := s.DeleteSucceededJob(ctx, job.ID); err != nil { + t.Fatal(err) + } + unknown, err := s.UnknownEpisodeIDs(ctx, "known-series", []string{ep.ID}) + if err != nil || len(unknown) != 0 { + t.Fatalf("削除後の既知記録: %v %v", unknown, err) + } + requeued, created, err := s.Enqueue(ctx, ep, "manual") + if err != nil || !created || requeued.Status != "queued" || requeued.Source != "manual" { + t.Fatalf("削除後の手動再投入: %+v %t %v", requeued, created, err) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index d36c1dd..cc1d9a8 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -111,6 +111,36 @@ func (s *Store) GetJob(ctx context.Context, id int64) (Job, error) { return scanJob(s.pool.QueryRow(ctx, `SELECT `+jobColumns+` FROM download_jobs WHERE id=$1`, id)) } +func (s *Store) GetSucceededJob(ctx context.Context, id int64) (Job, error) { + return scanJob(s.pool.QueryRow(ctx, `SELECT `+jobColumns+` FROM download_jobs WHERE id=$1 AND status='succeeded'`, id)) +} + +func (s *Store) ListSucceededJobs(ctx context.Context, limit int) ([]Job, error) { + if limit <= 0 { + return nil, errors.New("limit must be positive") + } + rows, err := s.pool.Query(ctx, `SELECT `+jobColumns+` FROM download_jobs + WHERE status='succeeded' ORDER BY finished_at DESC NULLS LAST, id DESC LIMIT $1`, limit) + 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) +} + // Retry は失敗・中断されたジョブの実行時情報を消して再投入する。 func (s *Store) Retry(ctx context.Context, id int64) error { tag, err := s.pool.Exec(ctx, `UPDATE download_jobs SET status='queued', phase=NULL, diff --git a/internal/web/handler.go b/internal/web/handler.go index add70b4..c59d0ef 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -21,6 +21,8 @@ type page struct { Newer *newerView Queue *queueView Subscriptions *subscriptionsView + Library *libraryView + Video *videoView } type JobStore interface { @@ -36,20 +38,24 @@ type JobStore interface { ListSubscriptions(context.Context) ([]store.Subscription, error) SetSubscriptionEnabled(context.Context, string, bool) error DeleteSubscription(context.Context, string) (bool, error) + ListSucceededJobs(context.Context, int) ([]store.Job, error) + GetSucceededJob(context.Context, int64) (store.Job, error) + DeleteSucceededJob(context.Context, int64) error } type server struct { - fetcher NewerFetcher - jobs JobStore - now func() time.Time - logger *log.Logger - zone *time.Location - tmpl *template.Template - cacheMu sync.Mutex - cache map[string]cacheEntry + fetcher NewerFetcher + jobs JobStore + videoDir string + now func() time.Time + logger *log.Logger + zone *time.Location + tmpl *template.Template + cacheMu sync.Mutex + cache map[string]cacheEntry } -func NewHandler(fetcher NewerFetcher, jobs JobStore, now func() time.Time, logger *log.Logger) http.Handler { +func NewHandler(fetcher NewerFetcher, jobs JobStore, videoDir string, now func() time.Time, logger *log.Logger) http.Handler { tmpl := template.Must(template.ParseFS(assets, "templates/*.html")) staticFiles, err := fs.Sub(assets, "static") if err != nil { @@ -59,7 +65,10 @@ func NewHandler(fetcher NewerFetcher, jobs JobStore, now func() time.Time, logge if err != nil { panic(err) } - s := &server{fetcher: fetcher, jobs: jobs, now: now, logger: logger, zone: zone, tmpl: tmpl, cache: make(map[string]cacheEntry)} + if videoDir == "" { + videoDir = "/videos" + } + s := &server{fetcher: fetcher, jobs: jobs, videoDir: videoDir, now: now, logger: logger, zone: zone, tmpl: tmpl, cache: make(map[string]cacheEntry)} mux := http.NewServeMux() mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticFiles))) mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { @@ -81,15 +90,9 @@ func NewHandler(fetcher NewerFetcher, jobs JobStore, now func() time.Time, logge mux.HandleFunc("POST /subscriptions/{id}/pause", s.pauseSubscription) mux.HandleFunc("POST /subscriptions/{id}/resume", s.resumeSubscription) mux.HandleFunc("POST /subscriptions/{id}/delete", s.deleteSubscription) - for path, title := range map[string]string{ - "GET /library": "ライブラリ", - } { - mux.HandleFunc(path, func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := s.tmpl.ExecuteTemplate(w, "layout", page{Title: title}); err != nil { - http.Error(w, "template error", http.StatusInternalServerError) - } - }) - } + mux.HandleFunc("GET /library", s.libraryPage) + mux.HandleFunc("GET /library/{id}", s.videoPage) + mux.HandleFunc("GET /videos/{id}", s.serveVideo) + mux.HandleFunc("POST /library/{id}/delete", s.deleteVideo) return mux } diff --git a/internal/web/library.go b/internal/web/library.go new file mode 100644 index 0000000..bc11359 --- /dev/null +++ b/internal/web/library.go @@ -0,0 +1,195 @@ +package web + +import ( + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + + "gitea.kazu634.com/kazu634/tver/internal/store" + "github.com/jackc/pgx/v5" +) + +const libraryLimit = 100 + +type libraryItemView struct { + ID int64 + ThumbnailURL, SeriesTitle, Title string + BroadcasterName, DurationText string + FileSizeText, FinishedAtText string +} + +type libraryView struct { + Items []libraryItemView +} + +type videoView struct { + libraryItemView + VideoURL string +} + +func (s *server) libraryPage(w http.ResponseWriter, r *http.Request) { + jobs, err := s.jobs.ListSucceededJobs(r.Context(), libraryLimit) + if err != nil { + s.logger.Printf("ライブラリの取得に失敗: %v", err) + http.Error(w, "ライブラリを取得できませんでした", http.StatusInternalServerError) + return + } + view := libraryView{Items: make([]libraryItemView, 0, len(jobs))} + for _, job := range jobs { + view.Items = append(view.Items, s.libraryItem(job)) + } + s.render(w, "layout", page{Title: "ライブラリ", Library: &view}) +} + +func (s *server) videoPage(w http.ResponseWriter, r *http.Request) { + job, ok := s.succeededJob(w, r) + 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)}}) +} + +func (s *server) libraryItem(job store.Job) libraryItemView { + item := libraryItemView{ID: job.ID, ThumbnailURL: "https://statics.tver.jp" + 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 (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 + } + path, err := secureVideoPath(s.videoDir, *job.FilePath, true) + if err != nil { + http.NotFound(w, r) + return + } + file, err := os.Open(path) + 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(path), info.ModTime(), file) +} + +func (s *server) deleteVideo(w http.ResponseWriter, r *http.Request) { + job, ok := s.succeededJob(w, r) + if !ok { + return + } + if job.FilePath != nil { + path, err := secureVideoPath(s.videoDir, *job.FilePath, false) + if err != nil { + http.Error(w, "動画ファイルの場所が不正です", http.StatusConflict) + return + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + s.logger.Printf("動画ファイルの削除に失敗: %v", err) + http.Error(w, "動画ファイルを削除できませんでした", http.StatusInternalServerError) + return + } + } + 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) +} + +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 +} + +func secureVideoPath(root, relative string, mustExist bool) (string, error) { + if relative == "" || filepath.IsAbs(relative) { + return "", errors.New("video path must be relative") + } + clean := filepath.Clean(relative) + if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", errors.New("video path leaves root") + } + rootAbs, err := filepath.Abs(root) + if err != nil { + return "", err + } + rootReal, err := filepath.EvalSymlinks(rootAbs) + if err != nil { + return "", err + } + candidate := filepath.Join(rootAbs, clean) + if err := insideRoot(rootAbs, candidate); err != nil { + return "", err + } + resolved, err := filepath.EvalSymlinks(candidate) + if err == nil { + if err := insideRoot(rootReal, resolved); err != nil { + return "", err + } + return candidate, nil + } + if mustExist || !errors.Is(err, os.ErrNotExist) { + return "", err + } + ancestor := filepath.Dir(candidate) + for { + resolvedAncestor, evalErr := filepath.EvalSymlinks(ancestor) + if evalErr == nil { + if err := insideRoot(rootReal, resolvedAncestor); err != nil { + return "", err + } + return candidate, nil + } + if !errors.Is(evalErr, os.ErrNotExist) || ancestor == rootAbs { + return "", evalErr + } + ancestor = filepath.Dir(ancestor) + } +} + +func insideRoot(root, path string) error { + rel, err := filepath.Rel(root, path) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return errors.New("path leaves video root") + } + return nil +} diff --git a/internal/web/library_test.go b/internal/web/library_test.go new file mode 100644 index 0000000..efeb680 --- /dev/null +++ b/internal/web/library_test.go @@ -0,0 +1,195 @@ +package web + +import ( + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gitea.kazu634.com/kazu634/tver/internal/store" +) + +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話`, + BroadcasterName: "放送局", DurationSeconds: 61, ThumbnailPath: "/thumb.jpg", Status: "succeeded", + FilePath: &relative, FileSizeBytes: &size, FinishedAt: &finished} +} + +func TestLibraryAndPlaybackPage(t *testing.T) { + dir := t.TempDir() + jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "show/video.mp4")}} + handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil) + page := getPage(handler, "/library") + if page.Code != http.StatusOK { + 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`} { + if !strings.Contains(body, want) { + t.Errorf("library missing %q", want) + } + } + if strings.Contains(body, "") { + t.Fatal("library fields were not escaped") + } + video := getPage(handler, "/library/1") + if video.Code != http.StatusOK || !strings.Contains(video.Body.String(), `