worker を追加(yt-dlp によるダウンロード、段階別進捗、ffprobe 検査、実行中の取り消し)

This commit is contained in:
2026-10-03 22:56:14 +09:00
parent ebf1cfcbc2
commit 6f150495d5
19 changed files with 1657 additions and 33 deletions
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"gitea.kazu634.com/kazu634/tver/internal/store"
workerpkg "gitea.kazu634.com/kazu634/tver/internal/worker"
"github.com/jackc/pgx/v5/pgxpool"
)
const staleAfter = 30 * time.Second
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
config, err := loadConfig(os.Getenv)
if err != nil {
log.Fatal(err)
}
pool, err := initializeDatabase(ctx, os.Getenv("DATABASE_URL"), time.Now(), pgxpool.New,
store.Migrate, func(ctx context.Context, pool *pgxpool.Pool, cutoff time.Time) (int64, error) {
return store.New(pool).RequeueStale(ctx, cutoff)
})
if err != nil {
log.Fatal(err)
}
defer pool.Close()
runner, err := workerpkg.New(store.New(pool), config, log.Default())
if err != nil {
log.Fatal(err)
}
log.Printf("worker started (concurrency=%d)", config.Concurrency)
if err := runner.Run(ctx); err != nil {
log.Fatal(err)
}
log.Print("worker stopped")
}
func loadConfig(getenv func(string) string) (workerpkg.Config, error) {
concurrency := 1
if value := strings.TrimSpace(getenv("WORKER_CONCURRENCY")); value != "" {
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 1 || parsed > 2 {
return workerpkg.Config{}, errors.New("WORKER_CONCURRENCY は 1 または 2 を指定してください")
}
concurrency = parsed
}
return workerpkg.Config{VideoDir: getenv("VIDEO_DIR"), YTDLPPath: getenv("YT_DLP_PATH"),
FFprobePath: getenv("FFPROBE_PATH"), Concurrency: concurrency}, nil
}
func initializeDatabase(ctx context.Context, databaseURL string, now time.Time,
connect func(context.Context, string) (*pgxpool.Pool, error),
migrate func(context.Context, *pgxpool.Pool) error,
requeue func(context.Context, *pgxpool.Pool, time.Time) (int64, error),
) (*pgxpool.Pool, error) {
if strings.TrimSpace(databaseURL) == "" {
return nil, errors.New("DATABASE_URL が設定されていません")
}
pool, err := connect(ctx, databaseURL)
if err != nil {
return nil, errors.New("DATABASE_URL から接続プールを作成できません")
}
if err := migrate(ctx, pool); err != nil {
pool.Close()
return nil, fmt.Errorf("DB マイグレーションに失敗: %w", err)
}
if _, err := requeue(ctx, pool, now.Add(-staleAfter)); err != nil {
pool.Close()
return nil, fmt.Errorf("停止中ジョブの復帰に失敗: %w", err)
}
return pool, nil
}
+97
View File
@@ -0,0 +1,97 @@
package main
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestLoadConfigConcurrency(t *testing.T) {
values := map[string]string{"VIDEO_DIR": "/media", "YT_DLP_PATH": "/bin/downloader", "FFPROBE_PATH": "/bin/probe"}
getenv := func(key string) string { return values[key] }
config, err := loadConfig(getenv)
if err != nil || config.Concurrency != 1 || config.VideoDir != "/media" || config.YTDLPPath != "/bin/downloader" || config.FFprobePath != "/bin/probe" {
t.Fatalf("既定設定: %+v %v", config, err)
}
values["WORKER_CONCURRENCY"] = "2"
config, err = loadConfig(getenv)
if err != nil || config.Concurrency != 2 {
t.Fatalf("並行数2: %+v %v", config, err)
}
for _, invalid := range []string{"0", "3", "bad"} {
values["WORKER_CONCURRENCY"] = invalid
if _, err := loadConfig(getenv); err == nil {
t.Errorf("不正な並行数 %q を許した", invalid)
}
}
}
func TestInitializeDatabaseRequiresURLMigratesAndRequeues(t *testing.T) {
ctx := context.Background()
now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
connectCalls, migrateCalls, requeueCalls := 0, 0, 0
connect := func(_ context.Context, url string) (*pgxpool.Pool, error) {
connectCalls++
if url != "postgres://test:secret@localhost/test" {
t.Errorf("URL=%q", url)
}
return pgxpool.New(ctx, url)
}
migrate := func(context.Context, *pgxpool.Pool) error { migrateCalls++; return nil }
requeue := func(_ context.Context, _ *pgxpool.Pool, cutoff time.Time) (int64, error) {
requeueCalls++
if want := now.Add(-staleAfter); !cutoff.Equal(want) {
t.Errorf("cutoff=%v want=%v", cutoff, want)
}
return 2, nil
}
if _, err := initializeDatabase(ctx, " ", now, connect, migrate, requeue); err == nil || !strings.Contains(err.Error(), "DATABASE_URL") {
t.Fatalf("未設定 URL: %v", err)
}
if connectCalls != 0 || migrateCalls != 0 || requeueCalls != 0 {
t.Fatalf("未設定時の呼び出し: %d %d %d", connectCalls, migrateCalls, requeueCalls)
}
pool, err := initializeDatabase(ctx, "postgres://test:secret@localhost/test", now, connect, migrate, requeue)
if err != nil || pool == nil {
t.Fatalf("初期化: %v", err)
}
pool.Close()
if connectCalls != 1 || migrateCalls != 1 || requeueCalls != 1 {
t.Fatalf("呼び出し回数: %d %d %d", connectCalls, migrateCalls, requeueCalls)
}
}
func TestInitializeDatabaseStopsOnMigrationOrRequeueFailure(t *testing.T) {
ctx := context.Background()
connect := func(context.Context, string) (*pgxpool.Pool, error) {
return pgxpool.New(ctx, "postgres://localhost/test")
}
requeueCalled := false
pool, err := initializeDatabase(ctx, "postgres://localhost/test", time.Now(), connect,
func(context.Context, *pgxpool.Pool) error { return errors.New("migration failed") },
func(context.Context, *pgxpool.Pool, time.Time) (int64, error) { requeueCalled = true; return 0, nil })
if pool != nil || err == nil || !strings.Contains(err.Error(), "migration failed") || requeueCalled {
t.Fatalf("migration failure: pool=%v err=%v requeue=%v", pool, err, requeueCalled)
}
pool, err = initializeDatabase(ctx, "postgres://localhost/test", time.Now(), connect,
func(context.Context, *pgxpool.Pool) error { return nil },
func(context.Context, *pgxpool.Pool, time.Time) (int64, error) { return 0, errors.New("requeue failed") })
if pool != nil || err == nil || !strings.Contains(err.Error(), "requeue failed") {
t.Fatalf("requeue failure: pool=%v err=%v", pool, err)
}
}
func TestInitializeDatabaseDoesNotLeakURL(t *testing.T) {
_, err := initializeDatabase(context.Background(), "postgres://user:secret@localhost/test", time.Now(),
func(context.Context, string) (*pgxpool.Pool, error) {
return nil, errors.New("postgres://user:secret@localhost/test refused")
}, func(context.Context, *pgxpool.Pool) error { return nil },
func(context.Context, *pgxpool.Pool, time.Time) (int64, error) { return 0, nil })
if err == nil || strings.Contains(err.Error(), "secret") {
t.Fatalf("URL 漏えい: %v", err)
}
}