feat(store,runner,web): channel unavailability notice (migration 013)
CI / Lint / Test / Vet (push) Successful in 26s
CI / Build & Import (push) Successful in 11s

YouTube channels that 404 on playlist discovery (deleted/private) are now:
1. Wrapped in domain.ErrChannelUnavailable by the YouTube adapter (instead of
   a generic error), so the runner can identify them without string-matching.
2. Stored per-user in channel_errors (migration 013, RLS-guarded) via runner's
   new UpsertChannelError path — removed from the generic Errors counter,
   counted separately as ChannelUnavailable.
3. Shown on the account page under "Unavailable channels" with name, chip-warn
   badge, and first-seen date, so users know why some subscribed channels
   produce no videos.
This commit is contained in:
2026-06-06 10:09:52 +02:00
parent 940f80899a
commit f1e9739900
13 changed files with 380 additions and 161 deletions
+17 -3
View File
@@ -38,6 +38,10 @@ type VideoStore interface {
// SetTranscriptStatus records the outcome of a transcript attempt: "none",
// "rate_limited" (stamps the backoff clock), or "fetched".
SetTranscriptStatus(ctx context.Context, userID, videoID, status string) error
// UpsertChannelError records a channel that returned HTTP 404 (deleted/private).
// Called when NewVideos returns domain.ErrChannelUnavailable; best-effort, errors
// are logged and never abort the pass.
UpsertChannelError(ctx context.Context, userID, channelID, channelTitle string) error
}
// Processor runs the core use case for a single video. *usecase.Engine
@@ -94,6 +98,7 @@ type Stats struct {
SkippedManual int // discovered but not queued, in manual mode
SkippedRateLimited int // 429'd previously and still inside the backoff window
Errors int
ChannelUnavailable int // channels that returned HTTP 404 (deleted/private)
}
// RunOnce performs a single pass over the user's subscriptions. Per-item errors
@@ -152,8 +157,17 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
for _, sub := range subs {
vids, err := r.src.NewVideos(ctx, sub)
if err != nil {
errs = append(errs, fmt.Errorf("new videos for %q: %w", sub.ChannelTitle, err))
stats.Errors++
var unavail *domain.ErrChannelUnavailable
if errors.As(err, &unavail) {
stats.ChannelUnavailable++
r.log.Warn("channel unavailable (playlist 404)", "channel", sub.ChannelTitle, "channel_id", sub.ChannelID)
if storeErr := r.store.UpsertChannelError(ctx, r.userID, unavail.ChannelID, unavail.ChannelTitle); storeErr != nil {
r.log.Warn("failed to store channel error", "err", storeErr)
}
} else {
errs = append(errs, fmt.Errorf("new videos for %q: %w", sub.ChannelTitle, err))
stats.Errors++
}
continue
}
for _, v := range vids {
@@ -251,7 +265,7 @@ func (r *Runner) Loop(ctx context.Context, interval time.Duration) error {
"candidates", stats.Candidates, "summarized", stats.Summarized,
"skipped_seen", stats.SkippedSeen, "skipped_no_text", stats.SkippedNoText,
"skipped_manual", stats.SkippedManual, "skipped_rate_limited", stats.SkippedRateLimited,
"errors", stats.Errors)
"channel_unavailable", stats.ChannelUnavailable, "errors", stats.Errors)
if err != nil {
r.log.Warn("run pass had errors", "err", err)
}