fix(youtube): discover via uploads playlist, not search.list (100x cheaper)
CI / Lint / Test / Vet (push) Successful in 5s
CI / Build & Import (push) Failing after 1s
CI / Mirror to GitHub (push) Has been skipped

NewVideos called search.list at 100 quota units/call. With ~143 channels one
discovery pass = 14,300 units > the 10,000/day YouTube Data API cap, exhausting
the whole day in a single loop (live quotaExceeded).

Switch to playlistItems.list (1 unit/call) against the channel's uploads
playlist. For a standard channel id UCxxxx the uploads playlist is UUxxxx,
derived at zero API cost (uploadsPlaylistID). Non-standard ids fall back to
channels.list (1 unit) to read contentDetails.relatedPlaylists.uploads. Newest-
first ordering and MaxVideosPerSubscription cap preserved.

Side effect: removing search.list also removes the accountDelegationForbidden
error that endpoint threw for one channel — no separate hardening needed.

New per-pass quota: /subscriptions (1) + ~1/channel discovery (143) + any
channels.list fallbacks ≈ 145 units/day, well under 10k. Caption fetch (ADR-010
timedtext) uses no Data API quota.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 23:15:27 +02:00
co-authored by Claude Opus 4.8
parent 92da25d01e
commit af1c163a11
2 changed files with 163 additions and 19 deletions
+92 -6
View File
@@ -112,18 +112,21 @@ func TestListSubscriptionsPaginates(t *testing.T) {
// --- new videos -------------------------------------------------------------
// TestNewVideos: discovery uses playlistItems.list (1 quota unit) against the
// uploads playlist derived from the channel id (UC_acme -> UU_acme, ADR via
// Worker H mission), NOT search.list (100 units). Items map newest-first.
func TestNewVideos(t *testing.T) {
a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/search" {
t.Errorf("unexpected path %q", r.URL.Path)
if r.URL.Path != "/playlistItems" {
t.Errorf("unexpected path %q (must use playlistItems, not search)", r.URL.Path)
}
if got := r.URL.Query().Get("channelId"); got != "UC_acme" {
t.Errorf("expected channelId=UC_acme, got %q", got)
if got := r.URL.Query().Get("playlistId"); got != "UU_acme" {
t.Errorf("expected playlistId=UU_acme (uploads playlist), got %q", got)
}
_, _ = w.Write([]byte(`{
"items": [
{"id": {"videoId": "vid1"}, "snippet": {"title": "Designing for Attention", "publishedAt": "2026-06-01T10:00:00Z"}},
{"id": {"videoId": "vid2"}, "snippet": {"title": "Second", "publishedAt": "2026-05-31T10:00:00Z"}}
{"snippet": {"title": "Designing for Attention", "publishedAt": "2026-06-01T10:00:00Z", "resourceId": {"videoId": "vid1"}}},
{"snippet": {"title": "Second", "publishedAt": "2026-05-31T10:00:00Z", "resourceId": {"videoId": "vid2"}}}
]
}`))
})
@@ -149,6 +152,89 @@ func TestNewVideos(t *testing.T) {
if v.PublishedAt.IsZero() {
t.Errorf("expected publishedAt parsed, got zero")
}
// Newest-first ordering preserved from the playlist response.
if vids[1].ProviderVideoID != "vid2" {
t.Errorf("expected newest-first ordering, got second=%q", vids[1].ProviderVideoID)
}
}
// TestNewVideosCapsAtMax: MaxVideosPerSubscription bounds the playlistItems
// page size (maxResults) and the number of returned videos.
func TestNewVideosCapsAtMax(t *testing.T) {
a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("maxResults"); got != "2" {
t.Errorf("expected maxResults=2 from cap, got %q", got)
}
_, _ = w.Write([]byte(`{
"items": [
{"snippet": {"title": "A", "publishedAt": "2026-06-03T10:00:00Z", "resourceId": {"videoId": "a"}}},
{"snippet": {"title": "B", "publishedAt": "2026-06-02T10:00:00Z", "resourceId": {"videoId": "b"}}}
]
}`))
})
a.cfg.MaxVideosPerSubscription = 2
vids, err := a.NewVideos(context.Background(), domain.Subscription{ID: "s1", UserID: "u1", ChannelID: "UC_acme"})
if err != nil {
t.Fatalf("NewVideos: %v", err)
}
if len(vids) != 2 {
t.Fatalf("expected cap of 2 videos, got %d", len(vids))
}
}
// TestUploadsPlaylistID covers the zero-cost UC->UU derivation, including
// non-standard ids that must fall through unchanged (handled via fallback).
func TestUploadsPlaylistID(t *testing.T) {
cases := []struct {
channel string
want string
derived bool
}{
{"UC_acme", "UU_acme", true},
{"UCabcdef123456", "UUabcdef123456", true},
{"HC_handle_style", "", false},
{"", "", false},
}
for _, c := range cases {
got, ok := uploadsPlaylistID(c.channel)
if ok != c.derived {
t.Errorf("uploadsPlaylistID(%q) derived=%v, want %v", c.channel, ok, c.derived)
}
if got != c.want {
t.Errorf("uploadsPlaylistID(%q)=%q, want %q", c.channel, got, c.want)
}
}
}
// TestNewVideosFallbackToChannelsList: a non-standard channel id can't be
// mapped UC->UU, so the adapter reads contentDetails.relatedPlaylists.uploads
// via channels.list (1 unit) and then fetches that playlist.
func TestNewVideosFallbackToChannelsList(t *testing.T) {
a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/channels":
if got := r.URL.Query().Get("id"); got != "HC_weird" {
t.Errorf("expected channels id=HC_weird, got %q", got)
}
_, _ = w.Write([]byte(`{"items":[{"contentDetails":{"relatedPlaylists":{"uploads":"UU_resolved"}}}]}`))
case "/playlistItems":
if got := r.URL.Query().Get("playlistId"); got != "UU_resolved" {
t.Errorf("expected playlistId=UU_resolved, got %q", got)
}
_, _ = w.Write([]byte(`{"items":[{"snippet":{"title":"X","publishedAt":"2026-06-01T10:00:00Z","resourceId":{"videoId":"x"}}}]}`))
default:
t.Errorf("unexpected path %q", r.URL.Path)
}
})
vids, err := a.NewVideos(context.Background(), domain.Subscription{ID: "s1", UserID: "u1", ChannelID: "HC_weird"})
if err != nil {
t.Fatalf("NewVideos fallback: %v", err)
}
if len(vids) != 1 || vids[0].ProviderVideoID != "x" {
t.Fatalf("expected 1 video via fallback, got %+v", vids)
}
}
// --- transcript: captions present (player response + timedtext baseUrl) ------