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
+9
View File
@@ -216,6 +216,9 @@ func (a *Adapter) NewVideos(ctx context.Context, sub domain.Subscription) ([]dom
var resp playlistItemListResponse
if err := a.getJSON(ctx, client, "/playlistItems", q, &resp); err != nil {
if isHTTP404(err) {
return nil, &domain.ErrChannelUnavailable{ChannelID: sub.ChannelID, ChannelTitle: sub.ChannelTitle}
}
return nil, fmt.Errorf("new videos for channel %q: %w", sub.ChannelID, err)
}
@@ -269,6 +272,12 @@ func (a *Adapter) resolveUploadsPlaylist(ctx context.Context, client *http.Clien
return resp.Items[0].ContentDetails.RelatedPlaylists.Uploads, nil
}
// isHTTP404 reports whether err came from a YouTube API call that returned HTTP 404.
// getRaw encodes the status as "youtube api <path>: status 404: ...".
func isHTTP404(err error) bool {
return err != nil && strings.Contains(err.Error(), "status 404")
}
// getJSON issues a GET and decodes a JSON body into out. A non-200 status is an
// error carrying a bounded slice of the response body for diagnosis.
func (a *Adapter) getJSON(ctx context.Context, client *http.Client, path string, q url.Values, out any) error {