ライブラリ・再生・削除を追加(Range 配信、パスの安全確認、共有ファイル名規則)

This commit is contained in:
2026-10-03 20:54:16 +09:00
parent 637ee5b81a
commit 276cd7b5b8
16 changed files with 869 additions and 30 deletions
+23 -20
View File
@@ -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
}
+195
View File
@@ -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
}
+195
View File
@@ -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: `<script>番組</script>`, Title: `<b>第1話</b>`,
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{"&lt;script&gt;番組&lt;/script&gt;", "&lt;b&gt;第1話&lt;/b&gt;", "放送局", "長さ: 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, "<script>番組</script>") {
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="動画ファイルと記録を削除しますか?"`) {
t.Fatalf("video page: %d %s", video.Code, video.Body.String())
}
for _, path := range []string{"/library/bad", "/library/99", "/library/1/delete"} {
response := getPage(handler, path)
want := http.StatusNotFound
if strings.HasSuffix(path, "/delete") {
want = http.StatusMethodNotAllowed
}
if response.Code != want {
t.Errorf("%s status=%d want=%d", path, response.Code, want)
}
if want == http.StatusMethodNotAllowed && !strings.Contains(response.Header().Get("Allow"), "POST") {
t.Errorf("%s Allow=%q", path, response.Header().Get("Allow"))
}
}
}
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)
for _, id := range []string{"0", "-1", "bad", "999999999999999999999999999999999999"} {
for _, path := range []string{"/library/" + id, "/videos/" + id} {
if got := getPage(handler, path).Code; got != http.StatusNotFound {
t.Errorf("GET %s: %d", path, got)
}
}
if got := postForm(handler, "/library/"+id+"/delete", nil).Code; got != http.StatusNotFound {
t.Errorf("POST delete id=%q: %d", id, got)
}
}
}
func TestVideoRangeAndMissingFile(t *testing.T) {
dir := t.TempDir()
if err := os.Mkdir(filepath.Join(dir, "show"), 0o755); err != nil {
t.Fatal(err)
}
content := []byte("0123456789")
if err := os.WriteFile(filepath.Join(dir, "show", "video.mp4"), content, 0o644); err != nil {
t.Fatal(err)
}
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "show/video.mp4")}}
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
request := httptest.NewRequest(http.MethodGet, "/videos/1?file_path=../../outside.mp4", nil)
request.Header.Set("Range", "bytes=2-5")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusPartialContent || response.Body.String() != "2345" || response.Header().Get("Content-Range") != "bytes 2-5/10" || response.Header().Get("Content-Type") != "video/mp4" {
t.Fatalf("range: status=%d range=%q type=%q body=%q", response.Code, response.Header().Get("Content-Range"), response.Header().Get("Content-Type"), response.Body.String())
}
full := getPage(handler, "/videos/1")
if full.Code != http.StatusOK || full.Body.String() != string(content) || full.Header().Get("Content-Type") != "video/mp4" || full.Header().Get("Accept-Ranges") != "bytes" {
t.Fatalf("full video: status=%d type=%q ranges=%q body=%q", full.Code, full.Header().Get("Content-Type"), full.Header().Get("Accept-Ranges"), full.Body.String())
}
request = httptest.NewRequest(http.MethodGet, "/videos/1", nil)
request.Header.Set("Range", "bytes=20-30")
response = httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusRequestedRangeNotSatisfiable || response.Header().Get("Content-Range") != "bytes */10" {
t.Fatalf("range outside: status=%d range=%q", response.Code, response.Header().Get("Content-Range"))
}
if err := os.Remove(filepath.Join(dir, "show", "video.mp4")); err != nil {
t.Fatal(err)
}
if got := getPage(handler, "/videos/1").Code; got != http.StatusNotFound {
t.Fatalf("missing file status=%d", got)
}
jobs.jobs["ep1"] = store.Job{ID: 1, Status: "queued"}
if got := getPage(handler, "/videos/1").Code; got != http.StatusNotFound {
t.Fatalf("unfinished status=%d", got)
}
if got := getPage(handler, "/library/1").Code; got != http.StatusNotFound {
t.Fatalf("unfinished playback page status=%d", got)
}
missingPath := libraryJob(1, "show/video.mp4")
missingPath.FilePath = nil
jobs.jobs["ep1"] = missingPath
if got := getPage(handler, "/videos/1").Code; got != http.StatusNotFound {
t.Fatalf("missing DB file path status=%d", got)
}
}
func TestVideoPathTraversalAndSymlinkAreRejected(t *testing.T) {
dir := t.TempDir()
outside := t.TempDir()
outsideFile := filepath.Join(outside, "outside.mp4")
if err := os.WriteFile(outsideFile, []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outsideFile, filepath.Join(dir, "link.mp4")); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(dir, "linked-directory")); err != nil {
t.Fatal(err)
}
for _, relative := range []string{"../outside.mp4", "show/../../outside.mp4", outsideFile, "link.mp4", "linked-directory/outside.mp4", "linked-directory/missing.mp4"} {
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, relative)}}
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
if got := getPage(handler, "/videos/1").Code; got != http.StatusNotFound {
t.Errorf("path %q status=%d", relative, got)
}
deleted := postForm(handler, "/library/1/delete", nil)
if deleted.Code != http.StatusConflict || len(jobs.jobs) != 1 {
t.Errorf("delete path %q status=%d jobs=%d", relative, deleted.Code, len(jobs.jobs))
}
}
}
func TestDeleteVideoFileThenRecord(t *testing.T) {
dir := t.TempDir()
if err := os.Mkdir(filepath.Join(dir, "show"), 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "show", "video.mp4")
if err := os.WriteFile(path, []byte("video"), 0o644); err != nil {
t.Fatal(err)
}
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "show/video.mp4")}}
fileWasGoneBeforeRecordDelete := false
jobs.beforeDeleteSucceeded = func() {
_, err := os.Stat(path)
fileWasGoneBeforeRecordDelete = errors.Is(err, os.ErrNotExist)
}
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
response := postForm(handler, "/library/1/delete", nil)
if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/library" || len(jobs.jobs) != 0 || !fileWasGoneBeforeRecordDelete {
t.Fatalf("delete: status=%d location=%q jobs=%d", response.Code, response.Header().Get("Location"), len(jobs.jobs))
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("file 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 TestDeleteFailureKeepsRecord(t *testing.T) {
dir := t.TempDir()
blocked := filepath.Join(dir, "blocked")
if err := os.Mkdir(blocked, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(blocked, "child"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "blocked")}}
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
response := postForm(handler, "/library/1/delete", nil)
if response.Code != http.StatusInternalServerError || len(jobs.jobs) != 1 || !strings.Contains(response.Body.String(), "動画ファイルを削除できませんでした") {
t.Fatalf("failure: status=%d jobs=%d body=%s", response.Code, len(jobs.jobs), response.Body.String())
}
}
+6 -2
View File
@@ -43,10 +43,14 @@ func newTestHandler(fetcher *fakeFetcher, now func() time.Time) http.Handler {
}
func newTestHandlerWithStore(fetcher *fakeFetcher, jobs *fakeJobStore, now func() time.Time) http.Handler {
return newTestHandlerWithVideoDir(fetcher, jobs, "", now)
}
func newTestHandlerWithVideoDir(fetcher *fakeFetcher, jobs *fakeJobStore, videoDir string, now func() time.Time) http.Handler {
if now == nil {
now = func() time.Time { return time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC) }
}
return NewHandler(fetcher, jobs, now, log.New(io.Discard, "", 0))
return NewHandler(fetcher, jobs, videoDir, now, log.New(io.Discard, "", 0))
}
func getPage(handler http.Handler, path string) *httptest.ResponseRecorder {
@@ -238,7 +242,7 @@ func TestNewerPageEscapesUntrustedEpisodeFields(t *testing.T) {
func TestNewerFailureShowsFriendlyMessageAndRedactsToken(t *testing.T) {
fetcher := &fakeFetcher{err: errors.New("GET https://platform-api.tver.jp/?platform_token=secret-123&x=1: upstream unavailable")}
var logs bytes.Buffer
handler := NewHandler(fetcher, &fakeJobStore{}, time.Now, log.New(&logs, "", 0))
handler := NewHandler(fetcher, &fakeJobStore{}, "", time.Now, log.New(&logs, "", 0))
response := getPage(handler, "/")
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "新着を取得できませんでした") {
t.Errorf("error page: status=%d body=%s", response.Code, response.Body.String())
+1
View File
@@ -24,3 +24,4 @@ main { max-width: 72rem; margin: 2rem auto; padding: 0 2rem; }
.queue-job h3 { margin: 0 0 .4rem; }
.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; }
+46
View File
@@ -24,6 +24,7 @@ type fakeJobStore struct {
registerErr error
subscribedCalls int
subscribedIDs []string
beforeDeleteSucceeded func()
err error
}
@@ -212,3 +213,48 @@ func (f *fakeJobStore) DeleteSubscription(_ context.Context, seriesID string) (b
delete(f.subscriptions, seriesID)
return true, nil
}
func (f *fakeJobStore) ListSucceededJobs(_ context.Context, limit int) ([]store.Job, error) {
if f.err != nil {
return nil, f.err
}
var result []store.Job
for _, job := range f.jobs {
if job.Status == "succeeded" {
result = append(result, job)
}
}
sort.Slice(result, func(i, j int) bool { return result[i].ID > result[j].ID })
if len(result) > limit {
result = result[:limit]
}
return result, nil
}
func (f *fakeJobStore) GetSucceededJob(_ context.Context, id int64) (store.Job, error) {
if f.err != nil {
return store.Job{}, f.err
}
for _, job := range f.jobs {
if job.ID == id && job.Status == "succeeded" {
return job, nil
}
}
return store.Job{}, pgx.ErrNoRows
}
func (f *fakeJobStore) DeleteSucceededJob(_ context.Context, id int64) error {
if f.beforeDeleteSucceeded != nil {
f.beforeDeleteSucceeded()
}
if f.err != nil {
return f.err
}
for episodeID, job := range f.jobs {
if job.ID == id && job.Status == "succeeded" {
delete(f.jobs, episodeID)
return nil
}
}
return store.ErrInvalidTransition
}
+4
View File
@@ -24,6 +24,10 @@
{{template "queue_page" .Queue}}
{{else if .Subscriptions}}
{{template "subscriptions_page" .Subscriptions}}
{{else if .Library}}
{{template "library_page" .Library}}
{{else if .Video}}
{{template "video_page" .Video}}
{{else}}
<h1>{{.Title}}</h1>
{{end}}
+34
View File
@@ -0,0 +1,34 @@
{{define "library_page"}}
<h1>ライブラリ</h1>
{{if .Items}}
<div class="episode-grid">
{{range .Items}}
<article class="episode-card">
<a href="/library/{{.ID}}">
<img src="{{.ThumbnailURL}}" alt="" loading="lazy">
<div class="episode-copy">
<h2>{{.SeriesTitle}}</h2>
<p>{{.Title}}</p>
<p>放送局: {{.BroadcasterName}}</p>
<p>長さ: {{.DurationText}}</p>
<p>サイズ: {{.FileSizeText}}</p>
<p>完了日時: {{.FinishedAtText}}</p>
</div>
</a>
</article>
{{end}}
</div>
{{else}}
<p>ダウンロード済みの動画はありません</p>
{{end}}
{{end}}
{{define "video_page"}}
<h1>{{.SeriesTitle}}</h1>
<h2>{{.Title}}</h2>
<video controls preload="metadata" src="{{.VideoURL}}"></video>
<p>放送局: {{.BroadcasterName}}/長さ: {{.DurationText}}/サイズ: {{.FileSizeText}}/完了日時: {{.FinishedAtText}}</p>
<form action="/library/{{.ID}}/delete" method="post" hx-confirm="動画ファイルと記録を削除しますか?">
<button type="submit">削除</button>
</form>
{{end}}