登録番組の定期確認を追加(24 時間ごと、予告の除外、確認エラーの記録)
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE subscriptions
|
||||
ADD COLUMN last_check_error_at TIMESTAMPTZ,
|
||||
ADD COLUMN last_check_error TEXT;
|
||||
|
||||
CREATE INDEX subscriptions_due_idx
|
||||
ON subscriptions (last_checked_at) WHERE enabled;
|
||||
@@ -59,6 +59,8 @@ type Subscription struct {
|
||||
Enabled bool
|
||||
CreatedAt time.Time
|
||||
LastCheckedAt *time.Time
|
||||
LastCheckErrorAt *time.Time
|
||||
LastCheckError *string
|
||||
JobCount int64
|
||||
}
|
||||
|
||||
@@ -301,6 +303,7 @@ func (s *Store) DeleteSubscription(ctx context.Context, seriesID string) (bool,
|
||||
|
||||
func (s *Store) ListSubscriptions(ctx context.Context) ([]Subscription, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT s.series_id, s.series_title, s.enabled, s.created_at, s.last_checked_at,
|
||||
s.last_check_error_at, s.last_check_error,
|
||||
(SELECT count(*) FROM download_jobs j WHERE j.series_id=s.series_id)
|
||||
FROM subscriptions s ORDER BY s.created_at DESC, s.series_id`)
|
||||
if err != nil {
|
||||
@@ -310,7 +313,8 @@ func (s *Store) ListSubscriptions(ctx context.Context) ([]Subscription, error) {
|
||||
var subs []Subscription
|
||||
for rows.Next() {
|
||||
var sub Subscription
|
||||
if err := rows.Scan(&sub.SeriesID, &sub.SeriesTitle, &sub.Enabled, &sub.CreatedAt, &sub.LastCheckedAt, &sub.JobCount); err != nil {
|
||||
if err := rows.Scan(&sub.SeriesID, &sub.SeriesTitle, &sub.Enabled, &sub.CreatedAt, &sub.LastCheckedAt,
|
||||
&sub.LastCheckErrorAt, &sub.LastCheckError, &sub.JobCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subs = append(subs, sub)
|
||||
|
||||
@@ -113,7 +113,7 @@ func TestMigrate(t *testing.T) {
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*), min(applied_at) FROM schema_migrations`).Scan(&count, &second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 2 || !first.Equal(second) {
|
||||
if count != 3 || !first.Equal(second) {
|
||||
t.Fatalf("再適用で履歴が変わった: %d %v %v", count, first, second)
|
||||
}
|
||||
}
|
||||
@@ -138,7 +138,7 @@ func TestMigrateConcurrent(t *testing.T) {
|
||||
if err := pool.QueryRow(context.Background(), `SELECT count(*) FROM schema_migrations`).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 2 {
|
||||
if count != 3 {
|
||||
t.Fatalf("適用件数: %d", count)
|
||||
}
|
||||
}
|
||||
@@ -166,7 +166,7 @@ func TestMigrateAddsKnownEpisodesToExistingDatabase(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM schema_migrations`).Scan(&count); err != nil || count != 2 {
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM schema_migrations`).Scan(&count); err != nil || count != 3 {
|
||||
t.Fatalf("移行履歴: %d %v", count, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO known_episodes (series_id, episode_id) VALUES ('existing', 'ep1')`); err != nil {
|
||||
@@ -180,6 +180,47 @@ func TestMigrateAddsKnownEpisodesToExistingDatabase(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateAddsSubscriptionCheckErrorsToVersionTwoDatabase(t *testing.T) {
|
||||
pool := testPool(t, false)
|
||||
ctx := context.Background()
|
||||
for _, name := range []string{"migrations/001_initial.sql", "migrations/002_known_episodes.sql"} {
|
||||
sql, err := migrations.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, string(sql), pgx.QueryExecModeSimpleProtocol); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `CREATE TABLE schema_migrations (version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ('migrations/001_initial.sql'), ('migrations/002_known_episodes.sql')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checked := time.Date(2026, 10, 1, 2, 3, 4, 0, time.UTC)
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO subscriptions (series_id, series_title, last_checked_at) VALUES ('existing-v2', '既存番組', $1)`, checked); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Migrate(ctx, pool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var title string
|
||||
var last time.Time
|
||||
var errorAt, errorMessage any
|
||||
if err := pool.QueryRow(ctx, `SELECT series_title, last_checked_at, last_check_error_at, last_check_error FROM subscriptions WHERE series_id='existing-v2'`).Scan(&title, &last, &errorAt, &errorMessage); err != nil || title != "既存番組" || !last.Equal(checked) || errorAt != nil || errorMessage != nil {
|
||||
t.Fatalf("v2データ保持と追加列: title=%q last=%v errorAt=%v error=%v err=%v", title, last, errorAt, errorMessage, err)
|
||||
}
|
||||
var indexDef string
|
||||
if err := pool.QueryRow(ctx, `SELECT indexdef FROM pg_indexes WHERE schemaname=current_schema() AND indexname='subscriptions_due_idx'`).Scan(&indexDef); err != nil || !strings.Contains(indexDef, "last_checked_at") || !strings.Contains(indexDef, "WHERE enabled") {
|
||||
t.Fatalf("期限抽出索引: %q %v", indexDef, err)
|
||||
}
|
||||
var versions int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM schema_migrations`).Scan(&versions); err != nil || versions != 3 {
|
||||
t.Fatalf("移行履歴: %d %v", versions, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadJobIndexes(t *testing.T) {
|
||||
pool := testPool(t, true)
|
||||
rows, err := pool.Query(context.Background(), `SELECT indexname, indexdef FROM pg_indexes WHERE schemaname=current_schema() AND tablename='download_jobs'`)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
@@ -66,6 +67,95 @@ func (s *Store) QueueUnseenEpisodes(ctx context.Context, seriesID string, episod
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ListDueSubscriptions は有効で、cutoff 以前に確認された(または未確認の)番組を返す。
|
||||
func (s *Store) ListDueSubscriptions(ctx context.Context, cutoff time.Time) ([]Subscription, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT series_id, series_title, enabled, created_at, last_checked_at,
|
||||
last_check_error_at, last_check_error, 0::bigint
|
||||
FROM subscriptions
|
||||
WHERE enabled AND (last_checked_at IS NULL OR last_checked_at <= $1)
|
||||
ORDER BY last_checked_at ASC NULLS FIRST, created_at, series_id`, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []Subscription
|
||||
for rows.Next() {
|
||||
var sub Subscription
|
||||
if err := rows.Scan(&sub.SeriesID, &sub.SeriesTitle, &sub.Enabled, &sub.CreatedAt,
|
||||
&sub.LastCheckedAt, &sub.LastCheckErrorAt, &sub.LastCheckError, &sub.JobCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, sub)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// CompleteSubscriptionCheck は全公開回を既知にし、しきい値を超える未確認回だけを投入する。
|
||||
// 最終確認日時の更新と前回エラーの消去も同じ取引で行う。
|
||||
func (s *Store) CompleteSubscriptionCheck(ctx context.Context, seriesID string, episodes []Episode, previewMaxSeconds int, checkedAt time.Time) (int, error) {
|
||||
if previewMaxSeconds < 0 {
|
||||
return 0, errors.New("preview threshold must not be negative")
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var enabled bool
|
||||
err = tx.QueryRow(ctx, `SELECT enabled FROM subscriptions WHERE series_id=$1 FOR UPDATE`, seriesID).Scan(&enabled)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, ErrInvalidTransition
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !enabled {
|
||||
return 0, ErrSubscriptionPaused
|
||||
}
|
||||
queued := 0
|
||||
for _, ep := range episodes {
|
||||
if ep.ID == "" || ep.SeriesID != seriesID || ep.DurationSeconds < 0 {
|
||||
return 0, fmt.Errorf("invalid episode identity for series %s", seriesID)
|
||||
}
|
||||
var inserted string
|
||||
err := tx.QueryRow(ctx, `INSERT INTO known_episodes (series_id, episode_id)
|
||||
VALUES ($1,$2) ON CONFLICT DO NOTHING RETURNING episode_id`, seriesID, ep.ID).Scan(&inserted)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if ep.DurationSeconds <= previewMaxSeconds {
|
||||
continue
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `INSERT INTO download_jobs
|
||||
(episode_id, series_id, series_title, title, broadcaster_name, duration_seconds, end_at, thumbnail_path, source)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'subscription')
|
||||
ON CONFLICT (episode_id) DO NOTHING`, ep.ID, seriesID, ep.SeriesTitle, ep.Title,
|
||||
ep.BroadcasterName, ep.DurationSeconds, ep.EndAt, ep.ThumbnailPath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
queued += int(tag.RowsAffected())
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE subscriptions SET last_checked_at=$2,
|
||||
last_check_error_at=NULL, last_check_error=NULL WHERE series_id=$1`, seriesID, checkedAt); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return queued, nil
|
||||
}
|
||||
|
||||
// RecordSubscriptionCheckFailure は取得失敗を記録し、最終確認日時は変更しない。
|
||||
func (s *Store) RecordSubscriptionCheckFailure(ctx context.Context, seriesID string, failedAt time.Time, message string) error {
|
||||
tag, err := s.pool.Exec(ctx, `UPDATE subscriptions SET last_check_error_at=$2, last_check_error=$3
|
||||
WHERE series_id=$1 AND enabled`, seriesID, failedAt, message)
|
||||
return transitionResult(tag.RowsAffected(), err)
|
||||
}
|
||||
|
||||
func addKnownAndMaybeQueue(ctx context.Context, tx pgx.Tx, seriesID string, episodes []Episode, queue bool) (int, error) {
|
||||
count := 0
|
||||
for _, ep := range episodes {
|
||||
|
||||
@@ -5,8 +5,144 @@ import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestListDueSubscriptionsBoundaryAndPaused(t *testing.T) {
|
||||
pool := testPool(t, true)
|
||||
s := New(pool)
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
|
||||
for _, id := range []string{"null", "old", "boundary", "fresh", "paused"} {
|
||||
if created, err := s.RegisterSubscription(ctx, id, "番組 "+id, nil, false); err != nil || !created {
|
||||
t.Fatalf("登録 %s: %t %v", id, created, err)
|
||||
}
|
||||
}
|
||||
updates := map[string]*time.Time{
|
||||
"null": nil, "old": timePointer(now.Add(-25 * time.Hour)),
|
||||
"boundary": timePointer(now.Add(-24 * time.Hour)), "fresh": timePointer(now.Add(-24*time.Hour + time.Microsecond)),
|
||||
"paused": timePointer(now.Add(-48 * time.Hour)),
|
||||
}
|
||||
for id, checked := range updates {
|
||||
if _, err := pool.Exec(ctx, `UPDATE subscriptions SET last_checked_at=$2 WHERE series_id=$1`, id, checked); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := s.SetSubscriptionEnabled(ctx, "paused", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
due, err := s.ListDueSubscriptions(ctx, now.Add(-24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(due) != 3 || due[0].SeriesID != "null" || due[1].SeriesID != "old" || due[2].SeriesID != "boundary" {
|
||||
t.Fatalf("24時間境界と一時停止: %+v", due)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteSubscriptionCheckKnownPreviewErrorAndAtomicity(t *testing.T) {
|
||||
pool := testPool(t, true)
|
||||
s := New(pool)
|
||||
ctx := context.Background()
|
||||
if created, err := s.RegisterSubscription(ctx, "series-check", "番組", []Episode{seriesEpisode("known", "series-check")}, false); err != nil || !created {
|
||||
t.Fatalf("登録: %t %v", created, err)
|
||||
}
|
||||
before := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
failedAt := time.Date(2026, 10, 3, 1, 2, 3, 0, time.UTC)
|
||||
if _, err := pool.Exec(ctx, `UPDATE subscriptions SET last_checked_at=$2 WHERE series_id=$1`, "series-check", before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.RecordSubscriptionCheckFailure(ctx, "series-check", failedAt, "network token=[REDACTED]"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var unchanged time.Time
|
||||
if err := pool.QueryRow(ctx, `SELECT last_checked_at FROM subscriptions WHERE series_id='series-check'`).Scan(&unchanged); err != nil || !unchanged.Equal(before) {
|
||||
t.Fatalf("失敗時に最終確認を更新: %v %v", unchanged, err)
|
||||
}
|
||||
checkedAt := time.Date(2026, 10, 3, 4, 5, 6, 0, time.UTC)
|
||||
preview := seriesEpisode("preview", "series-check")
|
||||
preview.DurationSeconds = 180
|
||||
normal := seriesEpisode("normal", "series-check")
|
||||
normal.DurationSeconds = 181
|
||||
count, err := s.CompleteSubscriptionCheck(ctx, "series-check", []Episode{
|
||||
seriesEpisode("known", "series-check"), preview, normal,
|
||||
}, 180, checkedAt)
|
||||
if err != nil || count != 1 {
|
||||
t.Fatalf("確認完了: count=%d err=%v", count, err)
|
||||
}
|
||||
unknown, err := s.UnknownEpisodeIDs(ctx, "series-check", []string{"known", "preview", "normal"})
|
||||
if err != nil || len(unknown) != 0 {
|
||||
t.Fatalf("予告を含む既知化: %v %v", unknown, err)
|
||||
}
|
||||
var jobs int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM download_jobs WHERE series_id='series-check'`).Scan(&jobs); err != nil || jobs != 1 {
|
||||
t.Fatalf("予告の投入除外: %d %v", jobs, err)
|
||||
}
|
||||
var last time.Time
|
||||
var errorAt, errorMessage any
|
||||
if err := pool.QueryRow(ctx, `SELECT last_checked_at, last_check_error_at, last_check_error FROM subscriptions WHERE series_id='series-check'`).Scan(&last, &errorAt, &errorMessage); err != nil || !last.Equal(checkedAt) || errorAt != nil || errorMessage != nil {
|
||||
t.Fatalf("成功時の日時・エラー消去: %v %v %v %v", last, errorAt, errorMessage, err)
|
||||
}
|
||||
|
||||
bad := seriesEpisode("bad", "series-check")
|
||||
bad.DurationSeconds = -1
|
||||
if _, err := s.CompleteSubscriptionCheck(ctx, "series-check", []Episode{seriesEpisode("rollback", "series-check"), bad}, 180, checkedAt.Add(time.Hour)); err == nil {
|
||||
t.Fatal("不正な回を含む確認が成功した")
|
||||
}
|
||||
unknown, err = s.UnknownEpisodeIDs(ctx, "series-check", []string{"rollback"})
|
||||
if err != nil || len(unknown) != 1 {
|
||||
t.Fatalf("失敗時の既知記録を巻き戻さない: %v %v", unknown, err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT last_checked_at FROM subscriptions WHERE series_id='series-check'`).Scan(&last); err != nil || !last.Equal(checkedAt) {
|
||||
t.Fatalf("失敗時の最終確認を巻き戻さない: %v %v", last, err)
|
||||
}
|
||||
if err := s.SetSubscriptionEnabled(ctx, "series-check", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.CompleteSubscriptionCheck(ctx, "series-check", nil, 180, checkedAt.Add(time.Hour)); !errors.Is(err, ErrSubscriptionPaused) {
|
||||
t.Fatalf("一時停止中の確認完了: %v", err)
|
||||
}
|
||||
if err := s.RecordSubscriptionCheckFailure(ctx, "series-check", failedAt, "ignored"); !errors.Is(err, ErrInvalidTransition) {
|
||||
t.Fatalf("一時停止中のエラー記録: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteSubscriptionCheckKeepsExistingManualJob(t *testing.T) {
|
||||
pool := testPool(t, true)
|
||||
s := New(pool)
|
||||
ctx := context.Background()
|
||||
if created, err := s.RegisterSubscription(ctx, "series-manual", "番組", nil, false); err != nil || !created {
|
||||
t.Fatalf("登録: %t %v", created, err)
|
||||
}
|
||||
episode := seriesEpisode("manual-existing", "series-manual")
|
||||
manual, _, err := s.Enqueue(ctx, episode, "manual")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkedAt := time.Date(2026, 10, 3, 4, 5, 6, 0, time.UTC)
|
||||
queued, err := s.CompleteSubscriptionCheck(ctx, "series-manual", []Episode{episode, episode}, 180, checkedAt)
|
||||
if err != nil || queued != 0 {
|
||||
t.Fatalf("既存手動ジョブの確認: queued=%d err=%v", queued, err)
|
||||
}
|
||||
got, err := s.GetJob(ctx, manual.ID)
|
||||
if err != nil || got.Source != "manual" {
|
||||
t.Fatalf("既存手動ジョブを変更した: %+v %v", got, err)
|
||||
}
|
||||
var jobs, known int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM download_jobs WHERE episode_id=$1`, episode.ID).Scan(&jobs); err != nil || jobs != 1 {
|
||||
t.Fatalf("ジョブが二重化した: %d %v", jobs, err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM known_episodes WHERE series_id=$1 AND episode_id=$2`, episode.SeriesID, episode.ID).Scan(&known); err != nil || known != 1 {
|
||||
t.Fatalf("既知記録: %d %v", known, err)
|
||||
}
|
||||
var last time.Time
|
||||
if err := pool.QueryRow(ctx, `SELECT last_checked_at FROM subscriptions WHERE series_id=$1`, episode.SeriesID).Scan(&last); err != nil || !last.Equal(checkedAt) {
|
||||
t.Fatalf("最終確認日時: %v %v", last, err)
|
||||
}
|
||||
}
|
||||
|
||||
func timePointer(value time.Time) *time.Time { return &value }
|
||||
|
||||
func seriesEpisode(id, seriesID string) Episode {
|
||||
ep := sampleEpisode(id)
|
||||
ep.SeriesID = seriesID
|
||||
|
||||
Reference in New Issue
Block a user