Skip to content

Commit 3f9e733

Browse files
committed
fix(youtube): honor account auth for list reads
1 parent cf9c515 commit 3f9e733

5 files changed

Lines changed: 203 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
- Docs: preserve paragraph-separating blank lines when replacing a single tab from Markdown. (#644)
1212
- Docs: add `docs cell-update` for non-destructive table-cell content replacement by table, row, and column. (#646)
1313
- Gmail: pause watch push Gmail API fetches per account while a 429 Retry-After circuit is open. (#643)
14+
- YouTube: let `videos list` and `comments list` use OAuth when `--account` is supplied, preserving the API-key fallback for unauthenticated public reads. (#664)
1415
- Docs: update the bundled `gog` agent skill to preserve broad user OAuth scopes during reauth and rely on command guards for scoped execution.
1516

1617
## 0.19.0 - 2026-05-22

internal/cmd/youtube.go

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@ import (
88

99
youtube "google.golang.org/api/youtube/v3"
1010

11+
"github.com/steipete/gogcli/internal/errfmt"
1112
"github.com/steipete/gogcli/internal/outfmt"
1213
"github.com/steipete/gogcli/internal/ui"
1314
)
1415

16+
const youtubeCommentsOAuthScope = "https://www.googleapis.com/auth/youtube.force-ssl"
17+
1518
type YouTubeCmd struct {
1619
Activities YouTubeActivitiesCmd `cmd:"" name:"activities" aliases:"activity" help:"List channel activities"`
1720
Videos YouTubeVideosCmd `cmd:"" name:"videos" aliases:"video" help:"List or get videos"`
@@ -133,7 +136,7 @@ func (c *YouTubeVideosListCmd) Run(ctx context.Context, flags *RootFlags) error
133136
return usage("--chart mostPopular requires --region (e.g. US)")
134137
}
135138

136-
svc, err := getYouTubeServiceWithAPIKey(ctx)
139+
svc, err := getYouTubeReadService(ctx, flags)
137140
if err != nil {
138141
return err
139142
}
@@ -289,7 +292,7 @@ func (c *YouTubeCommentsListCmd) Run(ctx context.Context, flags *RootFlags) erro
289292
return usage("use either --video-id or --channel-id, not both")
290293
}
291294

292-
svc, err := getYouTubeServiceWithAPIKey(ctx)
295+
svc, err := getYouTubeCommentsService(ctx, flags)
293296
if err != nil {
294297
return err
295298
}
@@ -304,7 +307,7 @@ func (c *YouTubeCommentsListCmd) Run(ctx context.Context, flags *RootFlags) erro
304307
}
305308
resp, err := call.Do()
306309
if err != nil {
307-
return err
310+
return wrapYouTubeCommentsError(err, flags)
308311
}
309312

310313
if outfmt.IsJSON(ctx) {
@@ -434,3 +437,52 @@ func validateYouTubeMax(limit int64) error {
434437
}
435438
return nil
436439
}
440+
441+
func getYouTubeReadService(ctx context.Context, flags *RootFlags) (*youtube.Service, error) {
442+
if youtubeAccountSelectorPresent(flags) {
443+
account, err := requireAccount(flags)
444+
if err != nil {
445+
return nil, err
446+
}
447+
return getYouTubeServiceForAccount(ctx, account)
448+
}
449+
return getYouTubeServiceWithAPIKey(ctx)
450+
}
451+
452+
func getYouTubeCommentsService(ctx context.Context, flags *RootFlags) (*youtube.Service, error) {
453+
if youtubeAccountSelectorPresent(flags) {
454+
account, err := requireAccount(flags)
455+
if err != nil {
456+
return nil, err
457+
}
458+
return getYouTubeCommentsServiceForAccount(ctx, account)
459+
}
460+
return getYouTubeServiceWithAPIKey(ctx)
461+
}
462+
463+
func youtubeAccountSelectorPresent(flags *RootFlags) bool {
464+
return flagAccount(flags) != "" || strings.TrimSpace(os.Getenv("GOG_ACCOUNT")) != "" || hasDirectAccessToken(flags)
465+
}
466+
467+
func wrapYouTubeCommentsError(err error, flags *RootFlags) error {
468+
if err == nil {
469+
return nil
470+
}
471+
errText := err.Error()
472+
if !strings.Contains(errText, "insufficientPermissions") &&
473+
!strings.Contains(errText, "insufficient authentication scopes") &&
474+
!strings.Contains(errText, "ACCESS_TOKEN_SCOPE_INSUFFICIENT") {
475+
return err
476+
}
477+
if !youtubeAccountSelectorPresent(flags) {
478+
return err
479+
}
480+
account, accountErr := requireAccount(flags)
481+
if accountErr != nil {
482+
return err
483+
}
484+
return errfmt.NewUserFacingError(
485+
fmt.Sprintf("youtube comments OAuth requires %s; re-authenticate with: gog auth add %s --services youtube --extra-scopes %s --force-consent", youtubeCommentsOAuthScope, account, youtubeCommentsOAuthScope),
486+
err,
487+
)
488+
}

internal/cmd/youtube_services.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ import (
1010
)
1111

1212
var (
13-
newYouTubeWithAPIKey = googleapi.NewYouTubeWithAPIKey
14-
newYouTubeForAccount = googleapi.NewYouTubeForAccount
13+
newYouTubeWithAPIKey = googleapi.NewYouTubeWithAPIKey
14+
newYouTubeForAccount = googleapi.NewYouTubeForAccount
15+
newYouTubeCommentsForAccount = googleapi.NewYouTubeCommentsForAccount
1516
)
1617

1718
func getYouTubeAPIKey() (string, error) {
@@ -37,3 +38,7 @@ func getYouTubeServiceWithAPIKey(ctx context.Context) (*youtube.Service, error)
3738
func getYouTubeServiceForAccount(ctx context.Context, account string) (*youtube.Service, error) {
3839
return newYouTubeForAccount(ctx, account)
3940
}
41+
42+
func getYouTubeCommentsServiceForAccount(ctx context.Context, account string) (*youtube.Service, error) {
43+
return newYouTubeCommentsForAccount(ctx, account)
44+
}

internal/cmd/youtube_test.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@ import (
44
"bytes"
55
"context"
66
"encoding/json"
7+
"errors"
78
"net/http"
89
"net/http/httptest"
910
"strings"
1011
"testing"
1112

1213
youtube "google.golang.org/api/youtube/v3"
14+
15+
"github.com/steipete/gogcli/internal/secrets"
1316
)
1417

1518
func TestYouTubeChannelsListWithAPIKey(t *testing.T) {
@@ -102,6 +105,125 @@ func TestYouTubeMineUsesOAuthService(t *testing.T) {
102105
}
103106
}
104107

108+
func TestYouTubeVideosListWithAccountUsesOAuthService(t *testing.T) {
109+
origOAuth := newYouTubeForAccount
110+
origAPIKey := newYouTubeWithAPIKey
111+
t.Cleanup(func() {
112+
newYouTubeForAccount = origOAuth
113+
newYouTubeWithAPIKey = origAPIKey
114+
})
115+
116+
var gotAccount string
117+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
118+
if r.URL.Path != "/youtube/v3/videos" {
119+
t.Fatalf("path = %s", r.URL.Path)
120+
}
121+
if got := r.URL.Query().Get("id"); got != "dQw4w9WgXcQ" {
122+
t.Fatalf("id = %q", got)
123+
}
124+
_ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{}})
125+
}))
126+
defer srv.Close()
127+
128+
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService)
129+
newYouTubeForAccount = func(_ context.Context, account string) (*youtube.Service, error) {
130+
gotAccount = account
131+
return svc, nil
132+
}
133+
newYouTubeWithAPIKey = func(context.Context, string) (*youtube.Service, error) {
134+
t.Fatal("API key service should not be used when account is configured")
135+
return nil, errors.New("unexpected API key service")
136+
}
137+
138+
err := runKong(t, &YouTubeVideosListCmd{}, []string{"--id", "dQw4w9WgXcQ", "--max", "1"}, newQuietUIContext(t), &RootFlags{Account: "[email protected]"})
139+
if err != nil {
140+
t.Fatalf("runKong: %v", err)
141+
}
142+
if gotAccount != "[email protected]" {
143+
t.Fatalf("account = %q", gotAccount)
144+
}
145+
}
146+
147+
func TestYouTubeCommentsListWithAccountUsesOAuthService(t *testing.T) {
148+
origOAuth := newYouTubeCommentsForAccount
149+
origAPIKey := newYouTubeWithAPIKey
150+
t.Cleanup(func() {
151+
newYouTubeCommentsForAccount = origOAuth
152+
newYouTubeWithAPIKey = origAPIKey
153+
})
154+
155+
var gotAccount string
156+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
157+
if r.URL.Path != "/youtube/v3/commentThreads" {
158+
t.Fatalf("path = %s", r.URL.Path)
159+
}
160+
if got := r.URL.Query().Get("videoId"); got != "dQw4w9WgXcQ" {
161+
t.Fatalf("videoId = %q", got)
162+
}
163+
_ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{}})
164+
}))
165+
defer srv.Close()
166+
167+
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService)
168+
newYouTubeCommentsForAccount = func(_ context.Context, account string) (*youtube.Service, error) {
169+
gotAccount = account
170+
return svc, nil
171+
}
172+
newYouTubeWithAPIKey = func(context.Context, string) (*youtube.Service, error) {
173+
t.Fatal("API key service should not be used when account is configured")
174+
return nil, errors.New("unexpected API key service")
175+
}
176+
177+
err := runKong(t, &YouTubeCommentsListCmd{}, []string{"--video-id", "dQw4w9WgXcQ", "--max", "1"}, newQuietUIContext(t), &RootFlags{Account: "[email protected]"})
178+
if err != nil {
179+
t.Fatalf("runKong: %v", err)
180+
}
181+
if gotAccount != "[email protected]" {
182+
t.Fatalf("account = %q", gotAccount)
183+
}
184+
}
185+
186+
func TestYouTubeVideosListWithAutoAccountUsesOAuthService(t *testing.T) {
187+
origOAuth := newYouTubeForAccount
188+
origAPIKey := newYouTubeWithAPIKey
189+
origStore := openSecretsStoreForAccount
190+
t.Cleanup(func() {
191+
newYouTubeForAccount = origOAuth
192+
newYouTubeWithAPIKey = origAPIKey
193+
openSecretsStoreForAccount = origStore
194+
})
195+
openSecretsStoreForAccount = func() (secrets.Store, error) {
196+
return &fakeSecretsStore{defaultAccount: "[email protected]"}, nil
197+
}
198+
199+
var gotAccount string
200+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
201+
if r.URL.Path != "/youtube/v3/videos" {
202+
t.Fatalf("path = %s", r.URL.Path)
203+
}
204+
_ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{}})
205+
}))
206+
defer srv.Close()
207+
208+
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService)
209+
newYouTubeForAccount = func(_ context.Context, account string) (*youtube.Service, error) {
210+
gotAccount = account
211+
return svc, nil
212+
}
213+
newYouTubeWithAPIKey = func(context.Context, string) (*youtube.Service, error) {
214+
t.Fatal("API key service should not be used when --account auto is configured")
215+
return nil, errors.New("unexpected API key service")
216+
}
217+
218+
err := runKong(t, &YouTubeVideosListCmd{}, []string{"--id", "dQw4w9WgXcQ", "--max", "1"}, newQuietUIContext(t), &RootFlags{Account: "auto"})
219+
if err != nil {
220+
t.Fatalf("runKong: %v", err)
221+
}
222+
if gotAccount != "[email protected]" {
223+
t.Fatalf("account = %q", gotAccount)
224+
}
225+
}
226+
105227
func TestYouTubeValidation(t *testing.T) {
106228
err := runKong(t, &YouTubeChannelsListCmd{}, []string{"--id", "UC123", "--max", "51"}, newQuietUIContext(t), &RootFlags{})
107229
if err == nil || !strings.Contains(err.Error(), "--max must be between 1 and 50") {

internal/googleapi/youtube.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import (
1515

1616
var errYouTubeAPIKeyRequired = errors.New("youtube: API key required (config set youtube_api_key KEY or GOG_YOUTUBE_API_KEY)")
1717

18+
const scopeYouTubeForceSSL = "https://www.googleapis.com/auth/youtube.force-ssl"
19+
1820
// NewYouTubeWithAPIKey creates a YouTube Data API v3 service client using an API key.
1921
// Use for public data: list by channelId, videoId, playlistId, etc.
2022
// API key can be set via config (youtube_api_key) or GOG_YOUTUBE_API_KEY.
@@ -60,3 +62,19 @@ func NewYouTubeForAccount(ctx context.Context, email string) (*youtube.Service,
6062

6163
return svc, nil
6264
}
65+
66+
// NewYouTubeCommentsForAccount creates a YouTube Data API v3 client for comment reads.
67+
// Google requires youtube.force-ssl for commentThreads.list; youtube.readonly is insufficient.
68+
func NewYouTubeCommentsForAccount(ctx context.Context, email string) (*youtube.Service, error) {
69+
opts, err := optionsForAccountScopes(ctx, string(googleauth.ServiceYouTube), email, []string{scopeYouTubeForceSSL})
70+
if err != nil {
71+
return nil, fmt.Errorf("youtube comments OAuth options: %w", err)
72+
}
73+
74+
svc, err := youtube.NewService(ctx, opts...)
75+
if err != nil {
76+
return nil, fmt.Errorf("youtube comments service for account: %w", err)
77+
}
78+
79+
return svc, nil
80+
}

0 commit comments

Comments
 (0)