feat(store,runner,web): channel unavailability notice (migration 013)
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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user