ライブラリを番組一覧・番組ページ・再生の3段にする(ステップ12)
This commit is contained in:
+11
-4
@@ -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/<ID>`形式の番組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`でも展開結果を確認する
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+179
-12
@@ -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) {
|
||||
|
||||
@@ -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: `<script>番組</script>`, Title: `<b>第1話</b>`,
|
||||
return store.Job{ID: id, EpisodeID: "ep1", SeriesID: "series1", SeriesTitle: `<script>番組</script>`, Title: `<b>第1話</b>`,
|
||||
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(), `<video controls`) || !strings.Contains(video.Body.String(), `src="/videos/1"`) || !strings.Contains(video.Body.String(), `hx-confirm="動画ファイルと記録を削除しますか?"`) {
|
||||
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(), `次の回はありません`) {
|
||||
t.Fatalf("video page: %d %s", video.Code, video.Body.String())
|
||||
}
|
||||
for _, path := range []string{"/library/bad", "/library/99", "/library/1/delete"} {
|
||||
@@ -57,6 +57,111 @@ func TestLibraryAndPlaybackPage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibrarySeriesPageSortingSubscriptionAndNotFound(t *testing.T) {
|
||||
old := libraryJob(2, "show/old.mp4")
|
||||
old.EpisodeID = "ep2"
|
||||
old.Title = "第2話"
|
||||
old.SeriesTitle = "旧番組名"
|
||||
old.BroadcasterName = "旧局"
|
||||
oldFinished := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
old.FinishedAt = &oldFinished
|
||||
latest := libraryJob(10, "show/latest.mp4")
|
||||
latest.EpisodeID = "ep10"
|
||||
latest.Title = "第10話"
|
||||
latest.SeriesTitle = `<em>新番組名</em>`
|
||||
latest.BroadcasterName = "新局"
|
||||
latestFinished := oldFinished.Add(time.Hour)
|
||||
latest.FinishedAt = &latestFinished
|
||||
jobs := &fakeJobStore{
|
||||
jobs: map[string]store.Job{"ep2": old, "ep10": latest},
|
||||
subscriptions: map[string]store.Subscription{"series1": {SeriesID: "series1"}},
|
||||
}
|
||||
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, t.TempDir(), nil)
|
||||
library := getPage(handler, "/library")
|
||||
if library.Code != http.StatusOK || !strings.Contains(library.Body.String(), "登録中") || !strings.Contains(library.Body.String(), "2本") {
|
||||
t.Fatalf("library series card: status=%d body=%s", library.Code, library.Body.String())
|
||||
}
|
||||
|
||||
page := getPage(handler, "/library/series/series1")
|
||||
if page.Code != http.StatusOK {
|
||||
t.Fatalf("series status=%d body=%s", page.Code, page.Body.String())
|
||||
}
|
||||
body := page.Body.String()
|
||||
for _, want := range []string{"ライブラリ", "<em>新番組名</em>", "登録中", "新局・2本・20.0 B", `/library/10`, `/library/2`, `?sort=natural`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("series page missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Index(body, `/library/10`) > strings.Index(body, `/library/2`) {
|
||||
t.Fatal("default order is not latest first")
|
||||
}
|
||||
natural := getPage(handler, "/library/series/series1?sort=natural")
|
||||
if natural.Code != http.StatusOK || strings.Index(natural.Body.String(), `/library/2`) > strings.Index(natural.Body.String(), `/library/10`) {
|
||||
t.Fatalf("natural order: status=%d body=%s", natural.Code, natural.Body.String())
|
||||
}
|
||||
if got := getPage(handler, "/library/series/missing").Code; got != http.StatusNotFound {
|
||||
t.Fatalf("missing series status=%d", got)
|
||||
}
|
||||
if got := getPage(handler, "/library/series/bad-id").Code; got != http.StatusNotFound {
|
||||
t.Fatalf("invalid series status=%d", got)
|
||||
}
|
||||
if got := getPage(handler, "/library/series/series1?sort=bad").Code; got != http.StatusBadRequest {
|
||||
t.Fatalf("invalid sort status=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryOmitsSubscriptionMarkerForUnregisteredSeries(t *testing.T) {
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "show/video.mp4")}}
|
||||
page := getPage(newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, t.TempDir(), nil), "/library")
|
||||
if page.Code != http.StatusOK || strings.Contains(page.Body.String(), "登録中") {
|
||||
t.Fatalf("unregistered series: status=%d body=%s", page.Code, page.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryPagesDoNotInspectVideoDirectory(t *testing.T) {
|
||||
missingVideoDir := filepath.Join(t.TempDir(), "missing-video-root")
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "missing/video.mp4")}}
|
||||
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, missingVideoDir, nil)
|
||||
for _, path := range []string{"/library", "/library/series/series1"} {
|
||||
response := getPage(handler, path)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Errorf("DB由来の一覧 %s: status=%d body=%s", path, response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(missingVideoDir); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("一覧表示が動画ルートを作成または走査した: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackPreviousAndNextNaturalOrder(t *testing.T) {
|
||||
jobs := make(map[string]store.Job)
|
||||
for _, data := range []struct {
|
||||
id int64
|
||||
epid string
|
||||
title string
|
||||
}{{2, "ep2", "第2話"}, {10, "ep10", "第10話"}, {9, "ep9", "第9話"}} {
|
||||
job := libraryJob(data.id, "show/video.mp4")
|
||||
job.EpisodeID = data.epid
|
||||
job.Title = data.title
|
||||
jobs[data.epid] = job
|
||||
}
|
||||
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, &fakeJobStore{jobs: jobs}, t.TempDir(), nil)
|
||||
middle := getPage(handler, "/library/9")
|
||||
for _, want := range []string{`href="/library/2"`, `前の回: 第2話`, `href="/library/10"`, `次の回: 第10話`} {
|
||||
if !strings.Contains(middle.Body.String(), want) {
|
||||
t.Errorf("middle playback missing %q", want)
|
||||
}
|
||||
}
|
||||
first := getPage(handler, "/library/2")
|
||||
if !strings.Contains(first.Body.String(), "前の回はありません") || !strings.Contains(first.Body.String(), `href="/library/9"`) {
|
||||
t.Fatalf("first playback navigation: %s", first.Body.String())
|
||||
}
|
||||
last := getPage(handler, "/library/10")
|
||||
if !strings.Contains(last.Body.String(), "次の回はありません") || !strings.Contains(last.Body.String(), `href="/library/9"`) {
|
||||
t.Fatalf("last playback navigation: %s", last.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryRejectsInvalidJobIDs(t *testing.T) {
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "show/video.mp4")}}
|
||||
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, t.TempDir(), nil)
|
||||
@@ -270,12 +375,58 @@ func TestDeleteVideoFileThenRecord(t *testing.T) {
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("file remains: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "show")); !os.IsNotExist(err) {
|
||||
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 {
|
||||
t.Fatalf("missing file delete: status=%d jobs=%d", got, len(jobs.jobs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRedirectsToSeriesWhenEpisodesRemainAndKeepsNonemptyFolder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.Mkdir(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.WriteFile(filepath.Join(dir, "show", "two.mp4"), []byte("two"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := libraryJob(1, "show/one.mp4")
|
||||
second := libraryJob(2, "show/two.mp4")
|
||||
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 {
|
||||
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 {
|
||||
t.Fatalf("nonempty series folder removed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "show", "two.mp4")); err != nil {
|
||||
t.Fatalf("remaining video removed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteVideoAtRootNeverRemovesVideoRoot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "video.mp4"), []byte("video"), 0o644); err != nil {
|
||||
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 {
|
||||
t.Fatalf("delete status=%d", response.Code)
|
||||
}
|
||||
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
|
||||
t.Fatalf("video root was removed: info=%v err=%v", info, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFailureKeepsRecord(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
blocked := filepath.Join(dir, "blocked")
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.kazu634.com/kazu634/tver/internal/store"
|
||||
)
|
||||
|
||||
func sortJobsNaturally(jobs []store.Job) {
|
||||
sort.SliceStable(jobs, func(i, j int) bool {
|
||||
if comparison := compareNaturalTitle(jobs[i].Title, jobs[j].Title); comparison != 0 {
|
||||
return comparison < 0
|
||||
}
|
||||
left, right := jobs[i].FinishedAt, jobs[j].FinishedAt
|
||||
if left == nil || right == nil {
|
||||
if left != right {
|
||||
return left == nil
|
||||
}
|
||||
} else if !left.Equal(*right) {
|
||||
return left.Before(*right)
|
||||
}
|
||||
return jobs[i].ID < jobs[j].ID
|
||||
})
|
||||
}
|
||||
|
||||
func compareNaturalTitle(left, right string) int {
|
||||
a, b := []rune(left), []rune(right)
|
||||
ai, bi := 0, 0
|
||||
for ai < len(a) && bi < len(b) {
|
||||
_, aIsDigit := decimalDigit(a[ai])
|
||||
_, bIsDigit := decimalDigit(b[bi])
|
||||
if aIsDigit && bIsDigit {
|
||||
var an, bn strings.Builder
|
||||
for ai < len(a) {
|
||||
digit, ok := decimalDigit(a[ai])
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
an.WriteByte(byte('0' + digit))
|
||||
ai++
|
||||
}
|
||||
for bi < len(b) {
|
||||
digit, ok := decimalDigit(b[bi])
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
bn.WriteByte(byte('0' + digit))
|
||||
bi++
|
||||
}
|
||||
if comparison := compareDecimal(an.String(), bn.String()); comparison != 0 {
|
||||
return comparison
|
||||
}
|
||||
continue
|
||||
}
|
||||
if a[ai] < b[bi] {
|
||||
return -1
|
||||
}
|
||||
if a[ai] > b[bi] {
|
||||
return 1
|
||||
}
|
||||
ai++
|
||||
bi++
|
||||
}
|
||||
if ai == len(a) && bi < len(b) {
|
||||
return -1
|
||||
}
|
||||
if bi == len(b) && ai < len(a) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func decimalDigit(r rune) (int, bool) {
|
||||
switch {
|
||||
case '0' <= r && r <= '9':
|
||||
return int(r - '0'), true
|
||||
case '0' <= r && r <= '9':
|
||||
return int(r - '0'), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func compareDecimal(left, right string) int {
|
||||
left = strings.TrimLeft(left, "0")
|
||||
right = strings.TrimLeft(right, "0")
|
||||
if left == "" {
|
||||
left = "0"
|
||||
}
|
||||
if right == "" {
|
||||
right = "0"
|
||||
}
|
||||
if len(left) < len(right) {
|
||||
return -1
|
||||
}
|
||||
if len(left) > len(right) {
|
||||
return 1
|
||||
}
|
||||
return strings.Compare(left, right)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.kazu634.com/kazu634/tver/internal/store"
|
||||
)
|
||||
|
||||
func TestSortJobsNaturally(t *testing.T) {
|
||||
base := time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC)
|
||||
jobs := []store.Job{
|
||||
{ID: 8, Title: "第10話", FinishedAt: timePointer(base)},
|
||||
{ID: 7, Title: "第2話", FinishedAt: timePointer(base)},
|
||||
{ID: 6, Title: "#10", FinishedAt: timePointer(base)},
|
||||
{ID: 5, Title: "#9", FinishedAt: timePointer(base)},
|
||||
{ID: 4, Title: "第2話", FinishedAt: timePointer(base.Add(time.Hour))},
|
||||
{ID: 3, Title: "第2話", FinishedAt: timePointer(base.Add(-time.Hour))},
|
||||
{ID: 2, Title: "第02話", FinishedAt: timePointer(base.Add(-time.Hour))},
|
||||
{ID: 1, Title: "おまけ", FinishedAt: timePointer(base)},
|
||||
}
|
||||
sortJobsNaturally(jobs)
|
||||
got := make([]int64, len(jobs))
|
||||
for i := range jobs {
|
||||
got[i] = jobs[i].ID
|
||||
}
|
||||
want := []int64{5, 6, 1, 2, 3, 7, 4, 8}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("自然順=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNaturalOrderUsesIDAfterEqualTitleAndFinishedAt(t *testing.T) {
|
||||
finished := time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC)
|
||||
jobs := []store.Job{
|
||||
{ID: 20, Title: "#9", FinishedAt: &finished},
|
||||
{ID: 10, Title: "#9", FinishedAt: &finished},
|
||||
}
|
||||
sortJobsNaturally(jobs)
|
||||
if jobs[0].ID != 10 || jobs[1].ID != 20 {
|
||||
t.Fatalf("同順位のID順: %+v", jobs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNaturalOrderTreatsLeadingZerosAsTheSameNumber(t *testing.T) {
|
||||
base := time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC)
|
||||
jobs := []store.Job{
|
||||
{ID: 30, Title: "数字なし", FinishedAt: timePointer(base.Add(-time.Hour))},
|
||||
{ID: 20, Title: "#09", FinishedAt: timePointer(base)},
|
||||
{ID: 10, Title: "#9", FinishedAt: timePointer(base.Add(time.Hour))},
|
||||
}
|
||||
sortJobsNaturally(jobs)
|
||||
if jobs[0].ID != 20 || jobs[1].ID != 10 || jobs[2].ID != 30 {
|
||||
t.Fatalf("先頭ゼロと数字なしの自然順: %+v", jobs)
|
||||
}
|
||||
}
|
||||
|
||||
func timePointer(value time.Time) *time.Time { return &value }
|
||||
@@ -10,6 +10,10 @@ main { max-width: 72rem; margin: 2rem auto; padding: 0 2rem; }
|
||||
.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; }
|
||||
.library-card-image { position: relative; }
|
||||
.episode-count-badge, .subscription-badge { display: inline-block; border-radius: 999px; padding: .15rem .5rem; font-size: .8rem; line-height: 1.2; }
|
||||
.episode-count-badge { position: absolute; right: .5rem; bottom: .5rem; color: #fff; background: rgba(23, 33, 43, .85); }
|
||||
.subscription-badge { color: #145a86; background: #e1f1fb; vertical-align: middle; }
|
||||
.episode-copy { padding: .8rem 1rem; }
|
||||
.episode-copy h2 { font-size: 1.1rem; margin: 0 0 .4rem; }
|
||||
.episode-copy p { margin: .3rem 0; }
|
||||
@@ -28,3 +32,22 @@ main { max-width: 72rem; margin: 2rem auto; padding: 0 2rem; }
|
||||
.queue-job p { margin: .4rem 0; }
|
||||
.queue-job pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
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-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-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; }
|
||||
|
||||
@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-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; }
|
||||
}
|
||||
|
||||
@@ -231,6 +231,71 @@ func (f *fakeJobStore) ListSucceededJobs(_ context.Context, limit int) ([]store.
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (f *fakeJobStore) ListLibrarySeries(_ context.Context) ([]store.LibrarySeries, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
bySeries := make(map[string]*store.LibrarySeries)
|
||||
latestIDs := make(map[string]int64)
|
||||
for _, job := range f.jobs {
|
||||
if job.Status != "succeeded" {
|
||||
continue
|
||||
}
|
||||
item := bySeries[job.SeriesID]
|
||||
if item == nil {
|
||||
item = &store.LibrarySeries{SeriesID: job.SeriesID}
|
||||
bySeries[job.SeriesID] = item
|
||||
}
|
||||
item.EpisodeCount++
|
||||
if job.FileSizeBytes != nil {
|
||||
item.TotalSizeBytes += *job.FileSizeBytes
|
||||
}
|
||||
current := store.Job{ID: latestIDs[job.SeriesID], FinishedAt: item.LatestFinishedAt}
|
||||
if item.SeriesTitle == "" || jobFinishedLater(job, current) {
|
||||
item.SeriesTitle = job.SeriesTitle
|
||||
item.LatestTitle = job.Title
|
||||
item.BroadcasterName = job.BroadcasterName
|
||||
item.ThumbnailPath = job.ThumbnailPath
|
||||
if job.FinishedAt != nil {
|
||||
finished := *job.FinishedAt
|
||||
item.LatestFinishedAt = &finished
|
||||
}
|
||||
latestIDs[job.SeriesID] = job.ID
|
||||
}
|
||||
_, item.Subscribed = f.subscriptions[job.SeriesID]
|
||||
}
|
||||
result := make([]store.LibrarySeries, 0, len(bySeries))
|
||||
for _, item := range bySeries {
|
||||
result = append(result, *item)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
left, right := result[i].LatestFinishedAt, result[j].LatestFinishedAt
|
||||
if left == nil || right == nil {
|
||||
if left != right {
|
||||
return left != nil
|
||||
}
|
||||
} else if !left.Equal(*right) {
|
||||
return left.After(*right)
|
||||
}
|
||||
return result[i].SeriesID < result[j].SeriesID
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (f *fakeJobStore) ListSucceededJobsBySeries(_ context.Context, seriesID string) ([]store.Job, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
var result []store.Job
|
||||
for _, job := range f.jobs {
|
||||
if job.Status == "succeeded" && job.SeriesID == seriesID {
|
||||
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
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
{{template "subscriptions_page" .Subscriptions}}
|
||||
{{else if .Library}}
|
||||
{{template "library_page" .Library}}
|
||||
{{else if .Series}}
|
||||
{{template "series_page" .Series}}
|
||||
{{else if .Video}}
|
||||
{{template "video_page" .Video}}
|
||||
{{else}}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
{{define "library_page"}}
|
||||
<h1>ライブラリ</h1>
|
||||
{{if .Items}}
|
||||
{{if .Series}}
|
||||
<p>{{.SeriesCount}}番組・{{.EpisodeCount}}本・{{.TotalSizeText}}</p>
|
||||
<div class="episode-grid">
|
||||
{{range .Items}}
|
||||
{{range .Series}}
|
||||
<article class="episode-card">
|
||||
<a href="/library/{{.ID}}">
|
||||
<img src="{{.ThumbnailURL}}" alt="" loading="lazy">
|
||||
<a href="/library/series/{{.SeriesID}}">
|
||||
<div class="library-card-image">
|
||||
<img src="{{.ThumbnailURL}}" alt="" loading="lazy">
|
||||
<span class="episode-count-badge">{{.EpisodeCount}}本</span>
|
||||
</div>
|
||||
<div class="episode-copy">
|
||||
<h2>{{.SeriesTitle}}</h2>
|
||||
<p>{{.Title}}</p>
|
||||
<h2>{{.SeriesTitle}} {{if .Subscribed}}<span class="subscription-badge">登録中</span>{{end}}</h2>
|
||||
<p>放送局: {{.BroadcasterName}}</p>
|
||||
<p>長さ: {{.DurationText}}</p>
|
||||
<p>サイズ: {{.FileSizeText}}</p>
|
||||
<p>完了日時: {{.FinishedAtText}}</p>
|
||||
<p>最新: {{.LatestTitle}}</p>
|
||||
<p>最終完了: {{.LatestFinishedText}}</p>
|
||||
<p>合計サイズ: {{.TotalSizeText}}</p>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
@@ -23,11 +26,38 @@
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "series_page"}}
|
||||
<nav class="breadcrumbs" aria-label="パンくず"><a href="/library">ライブラリ</a><span aria-hidden="true">›</span><span>{{.SeriesTitle}}</span></nav>
|
||||
<h1>{{.SeriesTitle}} {{if .Subscribed}}<span class="subscription-badge">登録中</span>{{end}}</h1>
|
||||
<p>{{.BroadcasterName}}・{{.EpisodeCount}}本・{{.TotalSizeText}}</p>
|
||||
<p class="library-sort">並び順:
|
||||
{{if eq .Sort "latest"}}<strong>完了が新しい順</strong>{{else}}<a href="/library/series/{{.SeriesID}}">完了が新しい順</a>{{end}}
|
||||
/
|
||||
{{if eq .Sort "natural"}}<strong>話の順</strong>{{else}}<a href="/library/series/{{.SeriesID}}?sort=natural">話の順</a>{{end}}
|
||||
</p>
|
||||
<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>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "video_page"}}
|
||||
<nav class="breadcrumbs" aria-label="パンくず"><a href="/library">ライブラリ</a><span aria-hidden="true">›</span><a href="{{.SeriesURL}}">{{.SeriesTitle}}</a><span aria-hidden="true">›</span><span>{{.Title}}</span></nav>
|
||||
<h1>{{.SeriesTitle}}</h1>
|
||||
<h2>{{.Title}}</h2>
|
||||
<video controls preload="metadata" src="{{.VideoURL}}"></video>
|
||||
<p>放送局: {{.BroadcasterName}}/長さ: {{.DurationText}}/サイズ: {{.FileSizeText}}/完了日時: {{.FinishedAtText}}</p>
|
||||
<nav class="episode-navigation" aria-label="前後の回">
|
||||
{{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="動画ファイルと記録を削除しますか?">
|
||||
<button type="submit">削除</button>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user