From fb0774827f76bd807428626038414523612146cb Mon Sep 17 00:00:00 2001 From: Andrew Beresford Date: Fri, 12 Jun 2026 21:08:35 +0100 Subject: [PATCH 01/10] feat: scaffold youtube subscriptions subcommand --- internal/cmd/youtube.go | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/internal/cmd/youtube.go b/internal/cmd/youtube.go index 254fbcfea..5fb5652ae 100644 --- a/internal/cmd/youtube.go +++ b/internal/cmd/youtube.go @@ -21,7 +21,8 @@ type YouTubeCmd struct { Playlists YouTubePlaylistsCmd `cmd:"" name:"playlists" aliases:"playlist" help:"List playlists"` Comments YouTubeCommentsCmd `cmd:"" name:"comments" aliases:"comment" help:"List comment threads"` Channels YouTubeChannelsCmd `cmd:"" name:"channels" aliases:"channel" help:"List channels"` - Search YouTubeSearchCmd `cmd:"" name:"search" aliases:"find" help:"Search YouTube for videos, channels, or playlists"` + Search YouTubeSearchCmd `cmd:"" name:"search" aliases:"find" help:"Search YouTube for videos, channels, or playlists"` + Subscriptions YouTubeSubscriptionsCmd `cmd:"" name:"subscriptions" aliases:"subscription" help:"Manage channel subscriptions"` } type YouTubeActivitiesCmd struct { @@ -538,6 +539,38 @@ func (c *YouTubeSearchListCmd) Run(ctx context.Context, flags *RootFlags) error return nil } +type YouTubeSubscriptionsCmd struct { + List YouTubeSubscriptionsListCmd `cmd:"" name:"list" aliases:"ls" help:"List subscriptions for authenticated user"` + Subscribe YouTubeSubscriptionsSubscribeCmd `cmd:"" name:"subscribe" help:"Subscribe to a channel"` + Unsubscribe YouTubeSubscriptionsUnsubscribeCmd `cmd:"" name:"unsubscribe" help:"Unsubscribe from a channel"` +} + +type YouTubeSubscriptionsListCmd struct { + Max int64 `name:"max" aliases:"limit" help:"Max results" default:"25"` + Page string `name:"page" help:"Page token"` +} + +func (c *YouTubeSubscriptionsListCmd) Run(ctx context.Context, flags *RootFlags) error { + return fmt.Errorf("not implemented") +} + +type YouTubeSubscriptionsSubscribeCmd struct { + ChannelID string `name:"channel-id" required:"" help:"Channel ID to subscribe to"` +} + +func (c *YouTubeSubscriptionsSubscribeCmd) Run(ctx context.Context, flags *RootFlags) error { + return fmt.Errorf("not implemented") +} + +type YouTubeSubscriptionsUnsubscribeCmd struct { + ID string `name:"id" help:"Subscription ID (from subscriptions list)"` + ChannelID string `name:"channel-id" help:"Channel ID (looked up to find subscription ID)"` +} + +func (c *YouTubeSubscriptionsUnsubscribeCmd) Run(ctx context.Context, flags *RootFlags) error { + return fmt.Errorf("not implemented") +} + func validateYouTubeMax(limit int64) error { if limit < 1 || limit > 50 { return usage("--max must be between 1 and 50") From 5e18fe2f68db975bd2d7759d2cf3a3ad4c9bb394 Mon Sep 17 00:00:00 2001 From: Andrew Beresford Date: Fri, 12 Jun 2026 21:09:50 +0100 Subject: [PATCH 02/10] style: gofmt youtube.go --- internal/cmd/youtube.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/cmd/youtube.go b/internal/cmd/youtube.go index 5fb5652ae..e24848c1b 100644 --- a/internal/cmd/youtube.go +++ b/internal/cmd/youtube.go @@ -16,11 +16,11 @@ import ( const youtubeCommentsOAuthScope = "https://www.googleapis.com/auth/youtube.force-ssl" type YouTubeCmd struct { - Activities YouTubeActivitiesCmd `cmd:"" name:"activities" aliases:"activity" help:"List channel activities"` - Videos YouTubeVideosCmd `cmd:"" name:"videos" aliases:"video" help:"List or get videos"` - Playlists YouTubePlaylistsCmd `cmd:"" name:"playlists" aliases:"playlist" help:"List playlists"` - Comments YouTubeCommentsCmd `cmd:"" name:"comments" aliases:"comment" help:"List comment threads"` - Channels YouTubeChannelsCmd `cmd:"" name:"channels" aliases:"channel" help:"List channels"` + Activities YouTubeActivitiesCmd `cmd:"" name:"activities" aliases:"activity" help:"List channel activities"` + Videos YouTubeVideosCmd `cmd:"" name:"videos" aliases:"video" help:"List or get videos"` + Playlists YouTubePlaylistsCmd `cmd:"" name:"playlists" aliases:"playlist" help:"List playlists"` + Comments YouTubeCommentsCmd `cmd:"" name:"comments" aliases:"comment" help:"List comment threads"` + Channels YouTubeChannelsCmd `cmd:"" name:"channels" aliases:"channel" help:"List channels"` Search YouTubeSearchCmd `cmd:"" name:"search" aliases:"find" help:"Search YouTube for videos, channels, or playlists"` Subscriptions YouTubeSubscriptionsCmd `cmd:"" name:"subscriptions" aliases:"subscription" help:"Manage channel subscriptions"` } From 065dab2504c36e3ecedd73fba9156d2438986250 Mon Sep 17 00:00:00 2001 From: Andrew Beresford Date: Fri, 12 Jun 2026 21:12:26 +0100 Subject: [PATCH 03/10] feat: implement youtube subscriptions list Co-Authored-By: Claude Sonnet 4.6 --- internal/cmd/youtube.go | 51 ++++++++++++++++++++++++++++++++- internal/cmd/youtube_test.go | 55 ++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/internal/cmd/youtube.go b/internal/cmd/youtube.go index e24848c1b..751c32076 100644 --- a/internal/cmd/youtube.go +++ b/internal/cmd/youtube.go @@ -551,7 +551,56 @@ type YouTubeSubscriptionsListCmd struct { } func (c *YouTubeSubscriptionsListCmd) Run(ctx context.Context, flags *RootFlags) error { - return fmt.Errorf("not implemented") + u := ui.FromContext(ctx) + if err := validateYouTubeMax(c.Max); err != nil { + return err + } + account, err := requireAccount(flags) + if err != nil { + return err + } + svc, err := getYouTubeServiceForAccount(ctx, account) + if err != nil { + return err + } + + resp, err := svc.Subscriptions.List([]string{"snippet"}). + Mine(true). + MaxResults(c.Max). + PageToken(c.Page). + Do() + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), map[string]any{ + "items": youtubeItemsOrEmpty(resp.Items), + "nextPageToken": resp.NextPageToken, + }) + } + if len(resp.Items) == 0 { + u.Err().Println("No subscriptions") + return nil + } + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ID\tCHANNEL_ID\tTITLE\tSUBSCRIBED_AT") + for _, s := range resp.Items { + channelID := "" + title := "" + subscribedAt := "" + if s.Snippet != nil { + title = s.Snippet.Title + subscribedAt = s.Snippet.PublishedAt + if s.Snippet.ResourceId != nil { + channelID = s.Snippet.ResourceId.ChannelId + } + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", s.Id, sanitizeTab(channelID), sanitizeTab(title), sanitizeTab(subscribedAt)) + } + printNextPageHint(u, resp.NextPageToken) + return nil } type YouTubeSubscriptionsSubscribeCmd struct { diff --git a/internal/cmd/youtube_test.go b/internal/cmd/youtube_test.go index a1c2737e8..21638f916 100644 --- a/internal/cmd/youtube_test.go +++ b/internal/cmd/youtube_test.go @@ -500,6 +500,61 @@ func TestYouTubeValidation(t *testing.T) { } } +func TestYouTubeSubscriptionsListMine(t *testing.T) { + var gotAccount string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/youtube/v3/subscriptions" { + t.Fatalf("path = %s", r.URL.Path) + } + if got := r.URL.Query().Get("mine"); got != "true" { + t.Fatalf("mine = %q", got) + } + if got := r.URL.Query().Get("maxResults"); got != "2" { + t.Fatalf("maxResults = %q", got) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": "SUB123", + "snippet": map[string]any{ + "title": "Cool Channel", + "publishedAt": "2025-01-01T00:00:00Z", + "resourceId": map[string]any{ + "kind": "youtube#channel", + "channelId": "UCcool", + }, + }, + }, + }, + "nextPageToken": "tok1", + }) + })) + defer srv.Close() + + svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService) + var stdout, stderr bytes.Buffer + ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, &stdout, &stderr), youtubeTestServices{ + Account: func(_ context.Context, account string) (*youtube.Service, error) { + gotAccount = account + return svc, nil + }, + }) + err := runKong(t, &YouTubeSubscriptionsListCmd{}, []string{"--max", "2"}, ctx, &RootFlags{Account: "me@example.com"}) + if err != nil { + t.Fatalf("runKong: %v", err) + } + if gotAccount != "me@example.com" { + t.Fatalf("account = %q", gotAccount) + } + out := stdout.String() + if !strings.Contains(out, "SUB123") || !strings.Contains(out, "UCcool") || !strings.Contains(out, "Cool Channel") { + t.Fatalf("stdout = %q", out) + } + if !strings.Contains(stderr.String(), "tok1") { + t.Fatalf("expected page token hint in stderr: %q", stderr.String()) + } +} + func TestYouTubeValidationRejectsBlankSelectorsBeforeService(t *testing.T) { ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), youtubeTestServices{ Account: unexpectedYouTubeTestService(t, "expected validation to fail before OAuth YouTube service creation"), From 82ec4cf2eac93269ed81d081e9e90e5c9d3d83ab Mon Sep 17 00:00:00 2001 From: Andrew Beresford Date: Fri, 12 Jun 2026 21:14:58 +0100 Subject: [PATCH 04/10] feat: implement youtube subscriptions subscribe Replace the stub Run with a real API call to svc.Subscriptions.Insert, remove the required:"" kong tag, and add a test covering the happy path. Co-Authored-By: Claude Sonnet 4.6 --- internal/cmd/youtube.go | 34 ++++++++++++++++++++++++++-- internal/cmd/youtube_test.go | 43 ++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/internal/cmd/youtube.go b/internal/cmd/youtube.go index 751c32076..6ce36dbd9 100644 --- a/internal/cmd/youtube.go +++ b/internal/cmd/youtube.go @@ -604,11 +604,41 @@ func (c *YouTubeSubscriptionsListCmd) Run(ctx context.Context, flags *RootFlags) } type YouTubeSubscriptionsSubscribeCmd struct { - ChannelID string `name:"channel-id" required:"" help:"Channel ID to subscribe to"` + ChannelID string `name:"channel-id" help:"Channel ID to subscribe to"` } func (c *YouTubeSubscriptionsSubscribeCmd) Run(ctx context.Context, flags *RootFlags) error { - return fmt.Errorf("not implemented") + u := ui.FromContext(ctx) + channelID := strings.TrimSpace(c.ChannelID) + if channelID == "" { + return usage("--channel-id is required") + } + account, err := requireAccount(flags) + if err != nil { + return err + } + svc, err := getYouTubeServiceForAccount(ctx, account) + if err != nil { + return err + } + + sub, err := svc.Subscriptions.Insert([]string{"snippet"}, &youtube.Subscription{ + Snippet: &youtube.SubscriptionSnippet{ + ResourceId: &youtube.ResourceId{ + Kind: "youtube#channel", + ChannelId: channelID, + }, + }, + }).Do() + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), sub) + } + u.Out().Printf("Subscribed: %s (subscription ID: %s)\n", channelID, sub.Id) + return nil } type YouTubeSubscriptionsUnsubscribeCmd struct { diff --git a/internal/cmd/youtube_test.go b/internal/cmd/youtube_test.go index 21638f916..e1b1d521e 100644 --- a/internal/cmd/youtube_test.go +++ b/internal/cmd/youtube_test.go @@ -555,6 +555,49 @@ func TestYouTubeSubscriptionsListMine(t *testing.T) { } } +func TestYouTubeSubscriptionsSubscribe(t *testing.T) { + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/youtube/v3/subscriptions" { + t.Fatalf("path = %s", r.URL.Path) + } + if r.Method != http.MethodPost { + t.Fatalf("method = %s", r.Method) + } + var err error + gotBody, err = io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "NEWSUB456", + "snippet": map[string]any{ + "resourceId": map[string]any{ + "kind": "youtube#channel", + "channelId": "UCnew", + }, + }, + }) + })) + defer srv.Close() + + svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService) + var stdout bytes.Buffer + ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, &stdout, io.Discard), youtubeTestServices{ + Account: fixedYouTubeTestService(svc), + }) + err := runKong(t, &YouTubeSubscriptionsSubscribeCmd{}, []string{"--channel-id", "UCnew"}, ctx, &RootFlags{Account: "me@example.com"}) + if err != nil { + t.Fatalf("runKong: %v", err) + } + if !strings.Contains(string(gotBody), "UCnew") { + t.Fatalf("request body missing channel ID: %s", gotBody) + } + if !strings.Contains(stdout.String(), "NEWSUB456") { + t.Fatalf("stdout missing subscription ID: %q", stdout.String()) + } +} + func TestYouTubeValidationRejectsBlankSelectorsBeforeService(t *testing.T) { ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), youtubeTestServices{ Account: unexpectedYouTubeTestService(t, "expected validation to fail before OAuth YouTube service creation"), From d7da86754ec4d804024f35a467946407bae8099f Mon Sep 17 00:00:00 2001 From: Andrew Beresford Date: Fri, 12 Jun 2026 21:18:06 +0100 Subject: [PATCH 05/10] feat: implement youtube subscriptions unsubscribe Co-Authored-By: Claude Sonnet 4.6 --- internal/cmd/youtube.go | 45 +++++++++++++- internal/cmd/youtube_test.go | 110 +++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 1 deletion(-) diff --git a/internal/cmd/youtube.go b/internal/cmd/youtube.go index 6ce36dbd9..26d980b88 100644 --- a/internal/cmd/youtube.go +++ b/internal/cmd/youtube.go @@ -647,7 +647,50 @@ type YouTubeSubscriptionsUnsubscribeCmd struct { } func (c *YouTubeSubscriptionsUnsubscribeCmd) Run(ctx context.Context, flags *RootFlags) error { - return fmt.Errorf("not implemented") + u := ui.FromContext(ctx) + subID := strings.TrimSpace(c.ID) + channelID := strings.TrimSpace(c.ChannelID) + if subID == "" && channelID == "" { + return usage("set --id or --channel-id") + } + if subID != "" && channelID != "" { + return usage("use either --id or --channel-id, not both") + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + svc, err := getYouTubeServiceForAccount(ctx, account) + if err != nil { + return err + } + + if channelID != "" { + resp, lookupErr := svc.Subscriptions.List([]string{"id"}). + Mine(true). + ForChannelId(channelID). + MaxResults(1). + Do() + if lookupErr != nil { + return lookupErr + } + if len(resp.Items) == 0 { + return fmt.Errorf("not subscribed to channel %s", channelID) + } + subID = resp.Items[0].Id + } + + if confirmErr := dryRunAndConfirmDestructive(ctx, flags, "youtube.subscriptions.delete", map[string]any{"id": subID}, fmt.Sprintf("unsubscribe (subscription ID: %s)", subID)); confirmErr != nil { + return confirmErr + } + + if err := svc.Subscriptions.Delete(subID).Do(); err != nil { + return err + } + + u.Out().Printf("Unsubscribed (subscription ID: %s)\n", subID) + return nil } func validateYouTubeMax(limit int64) error { diff --git a/internal/cmd/youtube_test.go b/internal/cmd/youtube_test.go index e1b1d521e..4e7b9b20b 100644 --- a/internal/cmd/youtube_test.go +++ b/internal/cmd/youtube_test.go @@ -598,6 +598,116 @@ func TestYouTubeSubscriptionsSubscribe(t *testing.T) { } } +func TestYouTubeSubscriptionsUnsubscribeByID(t *testing.T) { + var deletedID string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/youtube/v3/subscriptions" && r.Method == http.MethodDelete { + deletedID = r.URL.Query().Get("id") + w.WriteHeader(http.StatusNoContent) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer srv.Close() + + svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService) + ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), youtubeTestServices{ + Account: fixedYouTubeTestService(svc), + }) + err := runKong(t, &YouTubeSubscriptionsUnsubscribeCmd{}, []string{"--id", "SUB123"}, ctx, &RootFlags{Account: "me@example.com", Force: true}) + if err != nil { + t.Fatalf("runKong: %v", err) + } + if deletedID != "SUB123" { + t.Fatalf("deleted ID = %q", deletedID) + } +} + +func TestYouTubeSubscriptionsUnsubscribeByChannelID(t *testing.T) { + var deletedID string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/youtube/v3/subscriptions" && r.Method == http.MethodGet { + if got := r.URL.Query().Get("forChannelId"); got != "UCcool" { + t.Fatalf("forChannelId = %q", got) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{{"id": "SUB999"}}, + }) + return + } + if r.URL.Path == "/youtube/v3/subscriptions" && r.Method == http.MethodDelete { + deletedID = r.URL.Query().Get("id") + w.WriteHeader(http.StatusNoContent) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer srv.Close() + + svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService) + ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), youtubeTestServices{ + Account: fixedYouTubeTestService(svc), + }) + err := runKong(t, &YouTubeSubscriptionsUnsubscribeCmd{}, []string{"--channel-id", "UCcool"}, ctx, &RootFlags{Account: "me@example.com", Force: true}) + if err != nil { + t.Fatalf("runKong: %v", err) + } + if deletedID != "SUB999" { + t.Fatalf("deleted ID = %q", deletedID) + } +} + +func TestYouTubeSubscriptionsUnsubscribeChannelNotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/youtube/v3/subscriptions" && r.Method == http.MethodGet { + _ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{}}) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer srv.Close() + + svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService) + ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), youtubeTestServices{ + Account: fixedYouTubeTestService(svc), + }) + err := runKong(t, &YouTubeSubscriptionsUnsubscribeCmd{}, []string{"--channel-id", "UCmissing"}, ctx, &RootFlags{Account: "me@example.com", Force: true}) + if err == nil || !strings.Contains(err.Error(), "not subscribed") { + t.Fatalf("expected not-subscribed error, got %v", err) + } +} + +func TestYouTubeSubscriptionsUnsubscribeValidation(t *testing.T) { + ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), youtubeTestServices{ + Account: unexpectedYouTubeTestService(t, "should not reach service with missing args"), + }) + flags := &RootFlags{Account: "me@example.com", Force: true} + tests := []struct { + name string + args []string + want string + }{ + { + name: "neither id nor channel-id", + args: []string{}, + want: "set --id or --channel-id", + }, + { + name: "both id and channel-id", + args: []string{"--id", "SUB1", "--channel-id", "UC1"}, + want: "use either --id or --channel-id", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := runKong(t, &YouTubeSubscriptionsUnsubscribeCmd{}, tt.args, ctx, flags) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("expected error containing %q, got %v", tt.want, err) + } + }) + } +} + func TestYouTubeValidationRejectsBlankSelectorsBeforeService(t *testing.T) { ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), youtubeTestServices{ Account: unexpectedYouTubeTestService(t, "expected validation to fail before OAuth YouTube service creation"), From 60429215da465974e014f97d2abd22173face2d6 Mon Sep 17 00:00:00 2001 From: Andrew Beresford Date: Fri, 12 Jun 2026 21:20:50 +0100 Subject: [PATCH 06/10] fix: skip live API lookup in dry-run for unsubscribe --channel-id --- internal/cmd/youtube.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/internal/cmd/youtube.go b/internal/cmd/youtube.go index 26d980b88..622bd06bd 100644 --- a/internal/cmd/youtube.go +++ b/internal/cmd/youtube.go @@ -657,6 +657,13 @@ func (c *YouTubeSubscriptionsUnsubscribeCmd) Run(ctx context.Context, flags *Roo return usage("use either --id or --channel-id, not both") } + // For --id we have everything needed to confirm/dry-run before any I/O. + if subID != "" { + if confirmErr := dryRunAndConfirmDestructive(ctx, flags, "youtube.subscriptions.delete", map[string]any{"id": subID}, fmt.Sprintf("unsubscribe (subscription ID: %s)", subID)); confirmErr != nil { + return confirmErr + } + } + account, err := requireAccount(flags) if err != nil { return err @@ -667,6 +674,10 @@ func (c *YouTubeSubscriptionsUnsubscribeCmd) Run(ctx context.Context, flags *Roo } if channelID != "" { + if flags != nil && flags.DryRun { + // Skip live lookup in dry-run; we don't know the subscription ID yet. + return dryRunAndConfirmDestructive(ctx, flags, "youtube.subscriptions.delete", map[string]any{"channelId": channelID}, fmt.Sprintf("unsubscribe from channel %s", channelID)) + } resp, lookupErr := svc.Subscriptions.List([]string{"id"}). Mine(true). ForChannelId(channelID). @@ -679,10 +690,9 @@ func (c *YouTubeSubscriptionsUnsubscribeCmd) Run(ctx context.Context, flags *Roo return fmt.Errorf("not subscribed to channel %s", channelID) } subID = resp.Items[0].Id - } - - if confirmErr := dryRunAndConfirmDestructive(ctx, flags, "youtube.subscriptions.delete", map[string]any{"id": subID}, fmt.Sprintf("unsubscribe (subscription ID: %s)", subID)); confirmErr != nil { - return confirmErr + if confirmErr := dryRunAndConfirmDestructive(ctx, flags, "youtube.subscriptions.delete", map[string]any{"id": subID}, fmt.Sprintf("unsubscribe (subscription ID: %s)", subID)); confirmErr != nil { + return confirmErr + } } if err := svc.Subscriptions.Delete(subID).Do(); err != nil { From 6c34dc10555c6f9b3e0a909ab54c3d351dc11f88 Mon Sep 17 00:00:00 2001 From: Andrew Beresford Date: Fri, 12 Jun 2026 21:25:31 +0100 Subject: [PATCH 07/10] feat: add --all flag to subscriptions list for auto-pagination --- internal/cmd/youtube.go | 92 +++++++++++++++++++++++++++++++++++------ 1 file changed, 79 insertions(+), 13 deletions(-) diff --git a/internal/cmd/youtube.go b/internal/cmd/youtube.go index 622bd06bd..eed45bb10 100644 --- a/internal/cmd/youtube.go +++ b/internal/cmd/youtube.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "io" "os" "strings" @@ -546,14 +547,17 @@ type YouTubeSubscriptionsCmd struct { } type YouTubeSubscriptionsListCmd struct { - Max int64 `name:"max" aliases:"limit" help:"Max results" default:"25"` + Max int64 `name:"max" aliases:"limit" help:"Max results per page" default:"50"` Page string `name:"page" help:"Page token"` + All bool `name:"all" help:"Fetch all pages automatically"` } func (c *YouTubeSubscriptionsListCmd) Run(ctx context.Context, flags *RootFlags) error { u := ui.FromContext(ctx) - if err := validateYouTubeMax(c.Max); err != nil { - return err + if !c.All { + if err := validateYouTubeMax(c.Max); err != nil { + return err + } } account, err := requireAccount(flags) if err != nil { @@ -564,6 +568,10 @@ func (c *YouTubeSubscriptionsListCmd) Run(ctx context.Context, flags *RootFlags) return err } + if c.All { + return c.runAll(ctx, u, svc) + } + resp, err := svc.Subscriptions.List([]string{"snippet"}). Mine(true). MaxResults(c.Max). @@ -587,19 +595,77 @@ func (c *YouTubeSubscriptionsListCmd) Run(ctx context.Context, flags *RootFlags) defer flush() fmt.Fprintln(w, "ID\tCHANNEL_ID\tTITLE\tSUBSCRIBED_AT") for _, s := range resp.Items { - channelID := "" - title := "" - subscribedAt := "" - if s.Snippet != nil { - title = s.Snippet.Title - subscribedAt = s.Snippet.PublishedAt - if s.Snippet.ResourceId != nil { - channelID = s.Snippet.ResourceId.ChannelId + printSubscriptionRow(w, s) + } + printNextPageHint(u, resp.NextPageToken) + + return nil +} + +func printSubscriptionRow(w io.Writer, s *youtube.Subscription) { + channelID := "" + title := "" + subscribedAt := "" + if s.Snippet != nil { + title = s.Snippet.Title + subscribedAt = s.Snippet.PublishedAt + if s.Snippet.ResourceId != nil { + channelID = s.Snippet.ResourceId.ChannelId + } + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", s.Id, sanitizeTab(channelID), sanitizeTab(title), sanitizeTab(subscribedAt)) +} + +func (c *YouTubeSubscriptionsListCmd) runAll(ctx context.Context, u *ui.UI, svc *youtube.Service) error { + if outfmt.IsJSON(ctx) { + var all []*youtube.Subscription + pageToken := "" + for { + resp, err := svc.Subscriptions.List([]string{"snippet"}). + Mine(true). + MaxResults(50). + PageToken(pageToken). + Do() + if err != nil { + return err } + all = append(all, resp.Items...) + if resp.NextPageToken == "" { + break + } + pageToken = resp.NextPageToken } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", s.Id, sanitizeTab(channelID), sanitizeTab(title), sanitizeTab(subscribedAt)) + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), map[string]any{ + "items": youtubeItemsOrEmpty(all), + }) + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ID\tCHANNEL_ID\tTITLE\tSUBSCRIBED_AT") + pageToken := "" + total := 0 + for { + resp, err := svc.Subscriptions.List([]string{"snippet"}). + Mine(true). + MaxResults(50). + PageToken(pageToken). + Do() + if err != nil { + return err + } + for _, s := range resp.Items { + printSubscriptionRow(w, s) + } + total += len(resp.Items) + if resp.NextPageToken == "" { + break + } + pageToken = resp.NextPageToken + } + if total == 0 { + u.Err().Println("No subscriptions") } - printNextPageHint(u, resp.NextPageToken) return nil } From 9e2e3307d9904d2fb9c4c0ace1605ac01ee90939 Mon Sep 17 00:00:00 2001 From: Andrew Beresford Date: Fri, 12 Jun 2026 22:00:19 +0100 Subject: [PATCH 08/10] feat: add youtube playlists create and add commands Adds two new subcommands to `gog youtube playlists`: - `create --title TITLE [--description DESC] [--privacy public|unlisted|private]` Creates a new playlist for the authenticated account and prints the playlist ID. - `add --playlist-id ID --video-id ID [--position N]` Adds a video to a playlist. Appends by default; --position sets an explicit 0-based index. Both commands require OAuth write access (see companion scope change). JSON output is supported via -j/--json. Co-Authored-By: Claude Sonnet 4.6 --- internal/cmd/youtube.go | 95 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/internal/cmd/youtube.go b/internal/cmd/youtube.go index eed45bb10..cbbc3239e 100644 --- a/internal/cmd/youtube.go +++ b/internal/cmd/youtube.go @@ -194,7 +194,9 @@ func (c *YouTubeVideosListCmd) Run(ctx context.Context, flags *RootFlags) error } type YouTubePlaylistsCmd struct { - List YouTubePlaylistsListCmd `cmd:"" name:"list" aliases:"ls" help:"List playlists by channel or authenticated user"` + List YouTubePlaylistsListCmd `cmd:"" name:"list" aliases:"ls" help:"List playlists by channel or authenticated user"` + Create YouTubePlaylistsCreateCmd `cmd:"" name:"create" help:"Create a new playlist"` + Add YouTubePlaylistsAddCmd `cmd:"" name:"add" help:"Add a video to a playlist"` } type YouTubePlaylistsListCmd struct { @@ -277,6 +279,97 @@ func (c *YouTubePlaylistsListCmd) Run(ctx context.Context, flags *RootFlags) err return nil } +type YouTubePlaylistsCreateCmd struct { + Title string `name:"title" required:"" help:"Playlist title"` + Description string `name:"description" help:"Playlist description"` + Privacy string `name:"privacy" help:"Privacy: public, unlisted, private" default:"public" enum:"public,unlisted,private"` +} + +func (c *YouTubePlaylistsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + title := strings.TrimSpace(c.Title) + if title == "" { + return usage("--title is required") + } + account, err := requireAccount(flags) + if err != nil { + return err + } + svc, err := getYouTubeServiceForAccount(ctx, account) + if err != nil { + return err + } + + pl, err := svc.Playlists.Insert([]string{"snippet", "status"}, &youtube.Playlist{ + Snippet: &youtube.PlaylistSnippet{ + Title: title, + Description: strings.TrimSpace(c.Description), + }, + Status: &youtube.PlaylistStatus{ + PrivacyStatus: c.Privacy, + }, + }).Do() + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), pl) + } + u.Out().Printf("Created playlist: %s (ID: %s)\n", pl.Snippet.Title, pl.Id) + return nil +} + +type YouTubePlaylistsAddCmd struct { + PlaylistID string `name:"playlist-id" required:"" help:"Playlist ID"` + VideoID string `name:"video-id" required:"" help:"Video ID to add"` + Position int64 `name:"position" help:"Position in playlist (0-based); appends if not set" default:"-1"` +} + +func (c *YouTubePlaylistsAddCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + playlistID := strings.TrimSpace(c.PlaylistID) + videoID := strings.TrimSpace(c.VideoID) + if playlistID == "" { + return usage("--playlist-id is required") + } + if videoID == "" { + return usage("--video-id is required") + } + account, err := requireAccount(flags) + if err != nil { + return err + } + svc, err := getYouTubeServiceForAccount(ctx, account) + if err != nil { + return err + } + + item := &youtube.PlaylistItem{ + Snippet: &youtube.PlaylistItemSnippet{ + PlaylistId: playlistID, + ResourceId: &youtube.ResourceId{ + Kind: "youtube#video", + VideoId: videoID, + }, + }, + } + if c.Position >= 0 { + item.Snippet.Position = c.Position + } + + result, err := svc.PlaylistItems.Insert([]string{"snippet"}, item).Do() + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), result) + } + u.Out().Printf("Added video %s to playlist %s (item ID: %s)\n", videoID, playlistID, result.Id) + return nil +} + type YouTubeCommentsCmd struct { List YouTubeCommentsListCmd `cmd:"" name:"list" aliases:"ls" help:"List comment threads for a video or channel"` } From 855b202b546564b05ea58eb297e2780a0abb4b22 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 12 Jun 2026 23:52:15 +0100 Subject: [PATCH 09/10] feat(youtube): add subscription and playlist mutations Co-authored-by: Andrew Beresford --- CHANGELOG.md | 4 + README.md | 23 +- docs/commands.generated.md | 10 +- docs/commands/README.md | 12 +- docs/commands/gog-youtube-playlists-add.md | 48 +++ docs/commands/gog-youtube-playlists-create.md | 48 +++ docs/commands/gog-youtube-playlists-delete.md | 45 +++ docs/commands/gog-youtube-playlists-remove.md | 48 +++ docs/commands/gog-youtube-playlists.md | 6 +- .../gog-youtube-subscriptions-list.md | 48 +++ .../gog-youtube-subscriptions-subscribe.md | 46 +++ .../gog-youtube-subscriptions-unsubscribe.md | 47 +++ docs/commands/gog-youtube-subscriptions.md | 51 +++ docs/commands/gog-youtube.md | 3 +- docs/spec.md | 3 + internal/app/runtime.go | 1 + internal/cmd/youtube.go | 319 ++++++++++++------ internal/cmd/youtube_mutations_test.go | 319 ++++++++++++++++++ internal/cmd/youtube_services.go | 7 + internal/cmd/youtube_test.go | 11 +- internal/cmd/youtube_test_helpers_test.go | 2 + internal/googleapi/youtube.go | 17 + internal/googleapi/youtube_test.go | 21 ++ 23 files changed, 1031 insertions(+), 108 deletions(-) create mode 100644 docs/commands/gog-youtube-playlists-add.md create mode 100644 docs/commands/gog-youtube-playlists-create.md create mode 100644 docs/commands/gog-youtube-playlists-delete.md create mode 100644 docs/commands/gog-youtube-playlists-remove.md create mode 100644 docs/commands/gog-youtube-subscriptions-list.md create mode 100644 docs/commands/gog-youtube-subscriptions-subscribe.md create mode 100644 docs/commands/gog-youtube-subscriptions-unsubscribe.md create mode 100644 docs/commands/gog-youtube-subscriptions.md create mode 100644 internal/cmd/youtube_mutations_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c5d98516c..41fabd583 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.25.1 - Unreleased +### Added + +- YouTube: add subscription listing and management plus playlist create, add, remove, and delete commands with least-privilege OAuth, dry-run support, structured output, and destructive-operation confirmation. (#767) — thanks @beezly. + ## 0.25.0 - 2026-06-12 ### Added diff --git a/README.md b/README.md index 64ecd301a..47e729230 100644 --- a/README.md +++ b/README.md @@ -358,18 +358,37 @@ gog forms raw --pretty Docs: [`gog youtube`](docs/commands/gog-youtube.md), [`youtube channels`](docs/commands/gog-youtube-channels.md), [`youtube videos`](docs/commands/gog-youtube-videos.md), -[`youtube activities`](docs/commands/gog-youtube-activities.md). +[`youtube activities`](docs/commands/gog-youtube-activities.md), +[`youtube subscriptions`](docs/commands/gog-youtube-subscriptions.md), +[`youtube playlists`](docs/commands/gog-youtube-playlists.md). ```bash gog config set youtube_api_key YOUR_API_KEY gog yt channels list --id UC_x5XG1OV2P6uZZ5FSM9Ttw --json gog yt videos list --chart mostPopular --region US --max 5 gog yt activities list --mine -a you@gmail.com +gog yt subscriptions list --all -a you@gmail.com +gog yt playlists create --title "Research" -a you@gmail.com ``` For API-key reads, enable YouTube Data API v3 on the key's Google Cloud project and make sure API-key restrictions allow YouTube Data API calls. Authenticated -`--mine` reads use OAuth instead. +`--mine` reads use OAuth instead. Subscription and playlist mutations require +the narrower `youtube.force-ssl` write scope; authorize it explicitly before +the first write: + +```bash +gog auth add you@gmail.com --services youtube \ + --extra-scopes https://www.googleapis.com/auth/youtube.force-ssl \ + --force-consent +``` + +All YouTube mutations support `--dry-run`. Unsubscribe, playlist-item removal, +and playlist deletion also require confirmation or `--force`. New playlists +default to private; pass `--privacy unlisted` or `--privacy public` explicitly +to broaden visibility. The authenticated Google account must already have a +YouTube channel; initialize it once at youtube.com if the API reports +`youtubeSignupRequired`. ### Analytics and Search Console diff --git a/docs/commands.generated.md b/docs/commands.generated.md index 3f3fe441b..c1d7fb887 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -626,10 +626,18 @@ Generated from `gog schema --json`. - [`gog youtube (yt) channels (channel) list (ls) [flags]`](commands/gog-youtube-channels-list.md) - List channels by ID or authenticated user - [`gog youtube (yt) comments (comment) `](commands/gog-youtube-comments.md) - List comment threads - [`gog youtube (yt) comments (comment) list (ls) [flags]`](commands/gog-youtube-comments-list.md) - List comment threads for a video or channel - - [`gog youtube (yt) playlists (playlist) `](commands/gog-youtube-playlists.md) - List playlists + - [`gog youtube (yt) playlists (playlist) `](commands/gog-youtube-playlists.md) - Manage playlists + - [`gog youtube (yt) playlists (playlist) add --playlist-id=STRING --video-id=STRING [flags]`](commands/gog-youtube-playlists-add.md) - Add a video to a playlist + - [`gog youtube (yt) playlists (playlist) create --title=STRING [flags]`](commands/gog-youtube-playlists-create.md) - Create a new playlist + - [`gog youtube (yt) playlists (playlist) delete (del) `](commands/gog-youtube-playlists-delete.md) - Delete a playlist - [`gog youtube (yt) playlists (playlist) list (ls) [flags]`](commands/gog-youtube-playlists-list.md) - List playlists by channel or authenticated user + - [`gog youtube (yt) playlists (playlist) remove (rm) [flags]`](commands/gog-youtube-playlists-remove.md) - Remove a video from a playlist - [`gog youtube (yt) search (find) `](commands/gog-youtube-search.md) - Search YouTube for videos, channels, or playlists - [`gog youtube (yt) search (find) list (ls) [flags]`](commands/gog-youtube-search-list.md) - Search for videos, channels, or playlists + - [`gog youtube (yt) subscriptions (subscription) `](commands/gog-youtube-subscriptions.md) - Manage channel subscriptions + - [`gog youtube (yt) subscriptions (subscription) list (ls) [flags]`](commands/gog-youtube-subscriptions-list.md) - List subscriptions for authenticated user + - [`gog youtube (yt) subscriptions (subscription) subscribe [flags]`](commands/gog-youtube-subscriptions-subscribe.md) - Subscribe to a channel + - [`gog youtube (yt) subscriptions (subscription) unsubscribe [flags]`](commands/gog-youtube-subscriptions-unsubscribe.md) - Unsubscribe from a channel - [`gog youtube (yt) videos (video) `](commands/gog-youtube-videos.md) - List or get videos - [`gog youtube (yt) videos (video) list (ls) [flags]`](commands/gog-youtube-videos-list.md) - List videos by ID or chart - [`gog zoom [flags]`](commands/gog-zoom.md) - Zoom diff --git a/docs/commands/README.md b/docs/commands/README.md index 57e6fcc90..4bb2dc804 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -2,7 +2,7 @@ Every `gog` command has a generated docs page. The source of truth is the live CLI schema; run `make docs-commands` after changing command names, flags, help text, aliases, or arguments. -Generated pages: 634. +Generated pages: 642. ## Top-level Commands @@ -677,10 +677,18 @@ Generated pages: 634. - [gog youtube channels list](gog-youtube-channels-list.md) - List channels by ID or authenticated user - [gog youtube comments](gog-youtube-comments.md) - List comment threads - [gog youtube comments list](gog-youtube-comments-list.md) - List comment threads for a video or channel - - [gog youtube playlists](gog-youtube-playlists.md) - List playlists + - [gog youtube playlists](gog-youtube-playlists.md) - Manage playlists + - [gog youtube playlists add](gog-youtube-playlists-add.md) - Add a video to a playlist + - [gog youtube playlists create](gog-youtube-playlists-create.md) - Create a new playlist + - [gog youtube playlists delete](gog-youtube-playlists-delete.md) - Delete a playlist - [gog youtube playlists list](gog-youtube-playlists-list.md) - List playlists by channel or authenticated user + - [gog youtube playlists remove](gog-youtube-playlists-remove.md) - Remove a video from a playlist - [gog youtube search](gog-youtube-search.md) - Search YouTube for videos, channels, or playlists - [gog youtube search list](gog-youtube-search-list.md) - Search for videos, channels, or playlists + - [gog youtube subscriptions](gog-youtube-subscriptions.md) - Manage channel subscriptions + - [gog youtube subscriptions list](gog-youtube-subscriptions-list.md) - List subscriptions for authenticated user + - [gog youtube subscriptions subscribe](gog-youtube-subscriptions-subscribe.md) - Subscribe to a channel + - [gog youtube subscriptions unsubscribe](gog-youtube-subscriptions-unsubscribe.md) - Unsubscribe from a channel - [gog youtube videos](gog-youtube-videos.md) - List or get videos - [gog youtube videos list](gog-youtube-videos-list.md) - List videos by ID or chart - [gog zoom](gog-zoom.md) - Zoom diff --git a/docs/commands/gog-youtube-playlists-add.md b/docs/commands/gog-youtube-playlists-add.md new file mode 100644 index 000000000..a14a14766 --- /dev/null +++ b/docs/commands/gog-youtube-playlists-add.md @@ -0,0 +1,48 @@ +# `gog youtube playlists add` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Add a video to a playlist + +## Usage + +```bash +gog youtube (yt) playlists (playlist) add --playlist-id=STRING --video-id=STRING [flags] +``` + +## Parent + +- [gog youtube playlists](gog-youtube-playlists.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/drivelabels/docs/slides/contacts/tasks/people/sheets/forms/sites/appscript/analytics/searchconsole/youtube/photos) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--playlist-id` | `string` | | Playlist ID | +| `--position` | `int64` | -1 | Position in playlist (0-based); appends if not set | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--video-id` | `string` | | Video ID to add | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog youtube playlists](gog-youtube-playlists.md) +- [Command index](README.md) diff --git a/docs/commands/gog-youtube-playlists-create.md b/docs/commands/gog-youtube-playlists-create.md new file mode 100644 index 000000000..02d682d40 --- /dev/null +++ b/docs/commands/gog-youtube-playlists-create.md @@ -0,0 +1,48 @@ +# `gog youtube playlists create` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Create a new playlist + +## Usage + +```bash +gog youtube (yt) playlists (playlist) create --title=STRING [flags] +``` + +## Parent + +- [gog youtube playlists](gog-youtube-playlists.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/drivelabels/docs/slides/contacts/tasks/people/sheets/forms/sites/appscript/analytics/searchconsole/youtube/photos) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--description` | `string` | | Playlist description | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--privacy` | `string` | private | Privacy: public, unlisted, private | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `--title` | `string` | | Playlist title | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog youtube playlists](gog-youtube-playlists.md) +- [Command index](README.md) diff --git a/docs/commands/gog-youtube-playlists-delete.md b/docs/commands/gog-youtube-playlists-delete.md new file mode 100644 index 000000000..10ccb4d77 --- /dev/null +++ b/docs/commands/gog-youtube-playlists-delete.md @@ -0,0 +1,45 @@ +# `gog youtube playlists delete` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Delete a playlist + +## Usage + +```bash +gog youtube (yt) playlists (playlist) delete (del) +``` + +## Parent + +- [gog youtube playlists](gog-youtube-playlists.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/drivelabels/docs/slides/contacts/tasks/people/sheets/forms/sites/appscript/analytics/searchconsole/youtube/photos) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog youtube playlists](gog-youtube-playlists.md) +- [Command index](README.md) diff --git a/docs/commands/gog-youtube-playlists-remove.md b/docs/commands/gog-youtube-playlists-remove.md new file mode 100644 index 000000000..59eca08ab --- /dev/null +++ b/docs/commands/gog-youtube-playlists-remove.md @@ -0,0 +1,48 @@ +# `gog youtube playlists remove` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Remove a video from a playlist + +## Usage + +```bash +gog youtube (yt) playlists (playlist) remove (rm) [flags] +``` + +## Parent + +- [gog youtube playlists](gog-youtube-playlists.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/drivelabels/docs/slides/contacts/tasks/people/sheets/forms/sites/appscript/analytics/searchconsole/youtube/photos) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `--item-id` | `string` | | Playlist item ID to remove directly | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--playlist-id` | `string` | | Playlist ID (required with --video-id) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--video-id` | `string` | | Video ID to remove | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog youtube playlists](gog-youtube-playlists.md) +- [Command index](README.md) diff --git a/docs/commands/gog-youtube-playlists.md b/docs/commands/gog-youtube-playlists.md index b3b36cb19..6d366c4e6 100644 --- a/docs/commands/gog-youtube-playlists.md +++ b/docs/commands/gog-youtube-playlists.md @@ -2,7 +2,7 @@ > Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. -List playlists +Manage playlists ## Usage @@ -16,7 +16,11 @@ gog youtube (yt) playlists (playlist) ## Subcommands +- [gog youtube playlists add](gog-youtube-playlists-add.md) - Add a video to a playlist +- [gog youtube playlists create](gog-youtube-playlists-create.md) - Create a new playlist +- [gog youtube playlists delete](gog-youtube-playlists-delete.md) - Delete a playlist - [gog youtube playlists list](gog-youtube-playlists-list.md) - List playlists by channel or authenticated user +- [gog youtube playlists remove](gog-youtube-playlists-remove.md) - Remove a video from a playlist ## Flags diff --git a/docs/commands/gog-youtube-subscriptions-list.md b/docs/commands/gog-youtube-subscriptions-list.md new file mode 100644 index 000000000..b487295a0 --- /dev/null +++ b/docs/commands/gog-youtube-subscriptions-list.md @@ -0,0 +1,48 @@ +# `gog youtube subscriptions list` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +List subscriptions for authenticated user + +## Usage + +```bash +gog youtube (yt) subscriptions (subscription) list (ls) [flags] +``` + +## Parent + +- [gog youtube subscriptions](gog-youtube-subscriptions.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/drivelabels/docs/slides/contacts/tasks/people/sheets/forms/sites/appscript/analytics/searchconsole/youtube/photos) | +| `--all`
`--all-pages`
`--allpages` | `bool` | | Fetch all pages | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--max`
`--limit` | `int64` | 50 | Max results per page | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `--page`
`--cursor` | `string` | | Page token | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog youtube subscriptions](gog-youtube-subscriptions.md) +- [Command index](README.md) diff --git a/docs/commands/gog-youtube-subscriptions-subscribe.md b/docs/commands/gog-youtube-subscriptions-subscribe.md new file mode 100644 index 000000000..59606781d --- /dev/null +++ b/docs/commands/gog-youtube-subscriptions-subscribe.md @@ -0,0 +1,46 @@ +# `gog youtube subscriptions subscribe` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Subscribe to a channel + +## Usage + +```bash +gog youtube (yt) subscriptions (subscription) subscribe [flags] +``` + +## Parent + +- [gog youtube subscriptions](gog-youtube-subscriptions.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/drivelabels/docs/slides/contacts/tasks/people/sheets/forms/sites/appscript/analytics/searchconsole/youtube/photos) | +| `--channel-id` | `string` | | Channel ID to subscribe to | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog youtube subscriptions](gog-youtube-subscriptions.md) +- [Command index](README.md) diff --git a/docs/commands/gog-youtube-subscriptions-unsubscribe.md b/docs/commands/gog-youtube-subscriptions-unsubscribe.md new file mode 100644 index 000000000..bd9ff1345 --- /dev/null +++ b/docs/commands/gog-youtube-subscriptions-unsubscribe.md @@ -0,0 +1,47 @@ +# `gog youtube subscriptions unsubscribe` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Unsubscribe from a channel + +## Usage + +```bash +gog youtube (yt) subscriptions (subscription) unsubscribe [flags] +``` + +## Parent + +- [gog youtube subscriptions](gog-youtube-subscriptions.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/drivelabels/docs/slides/contacts/tasks/people/sheets/forms/sites/appscript/analytics/searchconsole/youtube/photos) | +| `--channel-id` | `string` | | Channel ID (looked up to find subscription ID) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `--id` | `string` | | Subscription ID (from subscriptions list) | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog youtube subscriptions](gog-youtube-subscriptions.md) +- [Command index](README.md) diff --git a/docs/commands/gog-youtube-subscriptions.md b/docs/commands/gog-youtube-subscriptions.md new file mode 100644 index 000000000..3a199b663 --- /dev/null +++ b/docs/commands/gog-youtube-subscriptions.md @@ -0,0 +1,51 @@ +# `gog youtube subscriptions` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Manage channel subscriptions + +## Usage + +```bash +gog youtube (yt) subscriptions (subscription) +``` + +## Parent + +- [gog youtube](gog-youtube.md) + +## Subcommands + +- [gog youtube subscriptions list](gog-youtube-subscriptions-list.md) - List subscriptions for authenticated user +- [gog youtube subscriptions subscribe](gog-youtube-subscriptions-subscribe.md) - Subscribe to a channel +- [gog youtube subscriptions unsubscribe](gog-youtube-subscriptions-unsubscribe.md) - Unsubscribe from a channel + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/drivelabels/docs/slides/contacts/tasks/people/sheets/forms/sites/appscript/analytics/searchconsole/youtube/photos) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog youtube](gog-youtube.md) +- [Command index](README.md) diff --git a/docs/commands/gog-youtube.md b/docs/commands/gog-youtube.md index 179308a5d..14534760f 100644 --- a/docs/commands/gog-youtube.md +++ b/docs/commands/gog-youtube.md @@ -19,8 +19,9 @@ gog youtube (yt) [flags] - [gog youtube activities](gog-youtube-activities.md) - List channel activities - [gog youtube channels](gog-youtube-channels.md) - List channels - [gog youtube comments](gog-youtube-comments.md) - List comment threads -- [gog youtube playlists](gog-youtube-playlists.md) - List playlists +- [gog youtube playlists](gog-youtube-playlists.md) - Manage playlists - [gog youtube search](gog-youtube-search.md) - Search YouTube for videos, channels, or playlists +- [gog youtube subscriptions](gog-youtube-subscriptions.md) - Manage channel subscriptions - [gog youtube videos](gog-youtube-videos.md) - List or get videos ## Flags diff --git a/docs/spec.md b/docs/spec.md index e0e7721cb..5f60dccca 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -477,6 +477,9 @@ We store a single refresh token per Google account email. - `https://www.googleapis.com/auth/directory.readonly` - People: - `profile` (OIDC) +- YouTube: + - `https://www.googleapis.com/auth/youtube.readonly` for normal account reads + - `https://www.googleapis.com/auth/youtube.force-ssl` as an explicit extra scope for comments and mutations - Photos: `https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata` - Photos Picker: `https://www.googleapis.com/auth/photospicker.mediaitems.readonly` (explicit opt-in) diff --git a/internal/app/runtime.go b/internal/app/runtime.go index aac5eb775..5bc12aee6 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -117,6 +117,7 @@ type Services struct { YouTubeAPIKey YouTubeServiceFactory YouTubeAccount YouTubeServiceFactory YouTubeComments YouTubeServiceFactory + YouTubeWrite YouTubeServiceFactory Zoom ZoomMeetingClientFactory DriveDownload DriveDownloadFunc DriveExport DriveExportFunc diff --git a/internal/cmd/youtube.go b/internal/cmd/youtube.go index cbbc3239e..9f6e98d9f 100644 --- a/internal/cmd/youtube.go +++ b/internal/cmd/youtube.go @@ -14,12 +14,12 @@ import ( "github.com/steipete/gogcli/internal/ui" ) -const youtubeCommentsOAuthScope = "https://www.googleapis.com/auth/youtube.force-ssl" +const youtubeForceSSLOAuthScope = "https://www.googleapis.com/auth/youtube.force-ssl" type YouTubeCmd struct { Activities YouTubeActivitiesCmd `cmd:"" name:"activities" aliases:"activity" help:"List channel activities"` Videos YouTubeVideosCmd `cmd:"" name:"videos" aliases:"video" help:"List or get videos"` - Playlists YouTubePlaylistsCmd `cmd:"" name:"playlists" aliases:"playlist" help:"List playlists"` + Playlists YouTubePlaylistsCmd `cmd:"" name:"playlists" aliases:"playlist" help:"Manage playlists"` Comments YouTubeCommentsCmd `cmd:"" name:"comments" aliases:"comment" help:"List comment threads"` Channels YouTubeChannelsCmd `cmd:"" name:"channels" aliases:"channel" help:"List channels"` Search YouTubeSearchCmd `cmd:"" name:"search" aliases:"find" help:"Search YouTube for videos, channels, or playlists"` @@ -197,6 +197,8 @@ type YouTubePlaylistsCmd struct { List YouTubePlaylistsListCmd `cmd:"" name:"list" aliases:"ls" help:"List playlists by channel or authenticated user"` Create YouTubePlaylistsCreateCmd `cmd:"" name:"create" help:"Create a new playlist"` Add YouTubePlaylistsAddCmd `cmd:"" name:"add" help:"Add a video to a playlist"` + Remove YouTubePlaylistsRemoveCmd `cmd:"" name:"remove" aliases:"rm" help:"Remove a video from a playlist"` + Delete YouTubePlaylistsDeleteCmd `cmd:"" name:"delete" aliases:"del" help:"Delete a playlist"` } type YouTubePlaylistsListCmd struct { @@ -282,7 +284,7 @@ func (c *YouTubePlaylistsListCmd) Run(ctx context.Context, flags *RootFlags) err type YouTubePlaylistsCreateCmd struct { Title string `name:"title" required:"" help:"Playlist title"` Description string `name:"description" help:"Playlist description"` - Privacy string `name:"privacy" help:"Privacy: public, unlisted, private" default:"public" enum:"public,unlisted,private"` + Privacy string `name:"privacy" help:"Privacy: public, unlisted, private" default:"private" enum:"public,unlisted,private"` } func (c *YouTubePlaylistsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { @@ -291,11 +293,19 @@ func (c *YouTubePlaylistsCreateCmd) Run(ctx context.Context, flags *RootFlags) e if title == "" { return usage("--title is required") } + description := strings.TrimSpace(c.Description) + if err := dryRunExit(ctx, flags, "youtube.playlists.create", map[string]any{ + "title": title, + "description": description, + "privacy": c.Privacy, + }); err != nil { + return err + } account, err := requireAccount(flags) if err != nil { return err } - svc, err := getYouTubeServiceForAccount(ctx, account) + svc, err := getYouTubeWriteServiceForAccount(ctx, account) if err != nil { return err } @@ -303,20 +313,26 @@ func (c *YouTubePlaylistsCreateCmd) Run(ctx context.Context, flags *RootFlags) e pl, err := svc.Playlists.Insert([]string{"snippet", "status"}, &youtube.Playlist{ Snippet: &youtube.PlaylistSnippet{ Title: title, - Description: strings.TrimSpace(c.Description), + Description: description, }, Status: &youtube.PlaylistStatus{ PrivacyStatus: c.Privacy, }, }).Do() if err != nil { - return err + return wrapYouTubeWriteError(err, flags) } if outfmt.IsJSON(ctx) { - return outfmt.WriteJSON(ctx, stdoutWriter(ctx), pl) + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), map[string]any{"playlist": pl}) } - u.Out().Printf("Created playlist: %s (ID: %s)\n", pl.Snippet.Title, pl.Id) + if outfmt.IsPlain(ctx) { + u.Out().Linef("id\t%s", pl.Id) + u.Out().Linef("title\t%s", title) + u.Out().Linef("privacy\t%s", c.Privacy) + return nil + } + u.Out().Printf("Created playlist: %s (ID: %s)\n", title, pl.Id) return nil } @@ -336,11 +352,24 @@ func (c *YouTubePlaylistsAddCmd) Run(ctx context.Context, flags *RootFlags) erro if videoID == "" { return usage("--video-id is required") } + if c.Position < -1 { + return usage("--position must be >= 0 when set") + } + request := map[string]any{ + "playlistId": playlistID, + "videoId": videoID, + } + if c.Position >= 0 { + request["position"] = c.Position + } + if err := dryRunExit(ctx, flags, "youtube.playlists.add", request); err != nil { + return err + } account, err := requireAccount(flags) if err != nil { return err } - svc, err := getYouTubeServiceForAccount(ctx, account) + svc, err := getYouTubeWriteServiceForAccount(ctx, account) if err != nil { return err } @@ -356,20 +385,146 @@ func (c *YouTubePlaylistsAddCmd) Run(ctx context.Context, flags *RootFlags) erro } if c.Position >= 0 { item.Snippet.Position = c.Position + item.Snippet.ForceSendFields = []string{"Position"} } result, err := svc.PlaylistItems.Insert([]string{"snippet"}, item).Do() if err != nil { - return err + return wrapYouTubeWriteError(err, flags) } if outfmt.IsJSON(ctx) { - return outfmt.WriteJSON(ctx, stdoutWriter(ctx), result) + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), map[string]any{"playlistItem": result}) + } + if outfmt.IsPlain(ctx) { + u.Out().Linef("item_id\t%s", result.Id) + u.Out().Linef("playlist_id\t%s", playlistID) + u.Out().Linef("video_id\t%s", videoID) + return nil } u.Out().Printf("Added video %s to playlist %s (item ID: %s)\n", videoID, playlistID, result.Id) return nil } +type YouTubePlaylistsRemoveCmd struct { + PlaylistID string `name:"playlist-id" help:"Playlist ID (required with --video-id)"` + VideoID string `name:"video-id" help:"Video ID to remove"` + ItemID string `name:"item-id" help:"Playlist item ID to remove directly"` +} + +func (c *YouTubePlaylistsRemoveCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + playlistID := strings.TrimSpace(c.PlaylistID) + videoID := strings.TrimSpace(c.VideoID) + itemID := strings.TrimSpace(c.ItemID) + if videoID == "" && itemID == "" { + return usage("set --video-id or --item-id") + } + if videoID != "" && itemID != "" { + return usage("use either --video-id or --item-id, not both") + } + if videoID != "" && playlistID == "" { + return usage("--playlist-id is required with --video-id") + } + + if itemID != "" { + if err := dryRunAndConfirmDestructive(ctx, flags, "youtube.playlists.remove", map[string]any{ + "itemId": itemID, + }, fmt.Sprintf("remove playlist item %s", itemID)); err != nil { + return err + } + } else if flags != nil && flags.DryRun { + return dryRunAndConfirmDestructive(ctx, flags, "youtube.playlists.remove", map[string]any{ + "playlistId": playlistID, + "videoId": videoID, + }, fmt.Sprintf("remove video %s from playlist %s", videoID, playlistID)) + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + svc, err := getYouTubeWriteServiceForAccount(ctx, account) + if err != nil { + return err + } + + if videoID != "" { + resp, lookupErr := svc.PlaylistItems.List([]string{"id"}). + PlaylistId(playlistID). + VideoId(videoID). + MaxResults(1). + Do() + if lookupErr != nil { + return wrapYouTubeWriteError(lookupErr, flags) + } + if len(resp.Items) == 0 { + return fmt.Errorf("video %s not found in playlist %s", videoID, playlistID) + } + itemID = resp.Items[0].Id + if err := dryRunAndConfirmDestructive(ctx, flagsWithoutDryRun(flags), "youtube.playlists.remove", map[string]any{ + "itemId": itemID, + }, fmt.Sprintf("remove playlist item %s", itemID)); err != nil { + return err + } + } + + if err := svc.PlaylistItems.Delete(itemID).Do(); err != nil { + return wrapYouTubeWriteError(err, flags) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), map[string]any{"removed": true, "itemId": itemID}) + } + if outfmt.IsPlain(ctx) { + u.Out().Linef("removed\ttrue") + u.Out().Linef("item_id\t%s", itemID) + return nil + } + u.Out().Printf("Removed playlist item %s\n", itemID) + return nil +} + +type YouTubePlaylistsDeleteCmd struct { + PlaylistID string `arg:"" name:"playlist-id" help:"Playlist ID to delete"` +} + +func (c *YouTubePlaylistsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + playlistID := strings.TrimSpace(c.PlaylistID) + if playlistID == "" { + return usage("playlist-id is required") + } + if err := dryRunAndConfirmDestructive(ctx, flags, "youtube.playlists.delete", map[string]any{ + "playlistId": playlistID, + }, fmt.Sprintf("delete playlist %s", playlistID)); err != nil { + return err + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + svc, err := getYouTubeWriteServiceForAccount(ctx, account) + if err != nil { + return err + } + if err := svc.Playlists.Delete(playlistID).Do(); err != nil { + return wrapYouTubeWriteError(err, flags) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), map[string]any{"deleted": true, "playlistId": playlistID}) + } + if outfmt.IsPlain(ctx) { + u.Out().Linef("deleted\ttrue") + u.Out().Linef("playlist_id\t%s", playlistID) + return nil + } + u.Out().Printf("Deleted playlist %s\n", playlistID) + return nil +} + type YouTubeCommentsCmd struct { List YouTubeCommentsListCmd `cmd:"" name:"list" aliases:"ls" help:"List comment threads for a video or channel"` } @@ -641,16 +796,14 @@ type YouTubeSubscriptionsCmd struct { type YouTubeSubscriptionsListCmd struct { Max int64 `name:"max" aliases:"limit" help:"Max results per page" default:"50"` - Page string `name:"page" help:"Page token"` - All bool `name:"all" help:"Fetch all pages automatically"` + Page string `name:"page" aliases:"cursor" help:"Page token"` + All bool `name:"all" aliases:"all-pages,allpages" help:"Fetch all pages"` } func (c *YouTubeSubscriptionsListCmd) Run(ctx context.Context, flags *RootFlags) error { u := ui.FromContext(ctx) - if !c.All { - if err := validateYouTubeMax(c.Max); err != nil { - return err - } + if err := validateYouTubeMax(c.Max); err != nil { + return err } account, err := requireAccount(flags) if err != nil { @@ -661,36 +814,39 @@ func (c *YouTubeSubscriptionsListCmd) Run(ctx context.Context, flags *RootFlags) return err } - if c.All { - return c.runAll(ctx, u, svc) + fetch := func(pageToken string) ([]*youtube.Subscription, string, error) { + resp, callErr := svc.Subscriptions.List([]string{"snippet"}). + Mine(true). + MaxResults(c.Max). + PageToken(pageToken). + Do() + if callErr != nil { + return nil, "", callErr + } + return youtubeItemsOrEmpty(resp.Items), resp.NextPageToken, nil } - - resp, err := svc.Subscriptions.List([]string{"snippet"}). - Mine(true). - MaxResults(c.Max). - PageToken(c.Page). - Do() + items, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch) if err != nil { return err } if outfmt.IsJSON(ctx) { return outfmt.WriteJSON(ctx, stdoutWriter(ctx), map[string]any{ - "items": youtubeItemsOrEmpty(resp.Items), - "nextPageToken": resp.NextPageToken, + "items": youtubeItemsOrEmpty(items), + "nextPageToken": nextPageToken, }) } - if len(resp.Items) == 0 { + if len(items) == 0 { u.Err().Println("No subscriptions") return nil } w, flush := tableWriter(ctx) defer flush() fmt.Fprintln(w, "ID\tCHANNEL_ID\tTITLE\tSUBSCRIBED_AT") - for _, s := range resp.Items { + for _, s := range items { printSubscriptionRow(w, s) } - printNextPageHint(u, resp.NextPageToken) + printNextPageHint(u, nextPageToken) return nil } @@ -709,59 +865,6 @@ func printSubscriptionRow(w io.Writer, s *youtube.Subscription) { fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", s.Id, sanitizeTab(channelID), sanitizeTab(title), sanitizeTab(subscribedAt)) } -func (c *YouTubeSubscriptionsListCmd) runAll(ctx context.Context, u *ui.UI, svc *youtube.Service) error { - if outfmt.IsJSON(ctx) { - var all []*youtube.Subscription - pageToken := "" - for { - resp, err := svc.Subscriptions.List([]string{"snippet"}). - Mine(true). - MaxResults(50). - PageToken(pageToken). - Do() - if err != nil { - return err - } - all = append(all, resp.Items...) - if resp.NextPageToken == "" { - break - } - pageToken = resp.NextPageToken - } - return outfmt.WriteJSON(ctx, stdoutWriter(ctx), map[string]any{ - "items": youtubeItemsOrEmpty(all), - }) - } - - w, flush := tableWriter(ctx) - defer flush() - fmt.Fprintln(w, "ID\tCHANNEL_ID\tTITLE\tSUBSCRIBED_AT") - pageToken := "" - total := 0 - for { - resp, err := svc.Subscriptions.List([]string{"snippet"}). - Mine(true). - MaxResults(50). - PageToken(pageToken). - Do() - if err != nil { - return err - } - for _, s := range resp.Items { - printSubscriptionRow(w, s) - } - total += len(resp.Items) - if resp.NextPageToken == "" { - break - } - pageToken = resp.NextPageToken - } - if total == 0 { - u.Err().Println("No subscriptions") - } - return nil -} - type YouTubeSubscriptionsSubscribeCmd struct { ChannelID string `name:"channel-id" help:"Channel ID to subscribe to"` } @@ -772,11 +875,16 @@ func (c *YouTubeSubscriptionsSubscribeCmd) Run(ctx context.Context, flags *RootF if channelID == "" { return usage("--channel-id is required") } + if err := dryRunExit(ctx, flags, "youtube.subscriptions.subscribe", map[string]any{ + "channelId": channelID, + }); err != nil { + return err + } account, err := requireAccount(flags) if err != nil { return err } - svc, err := getYouTubeServiceForAccount(ctx, account) + svc, err := getYouTubeWriteServiceForAccount(ctx, account) if err != nil { return err } @@ -790,11 +898,16 @@ func (c *YouTubeSubscriptionsSubscribeCmd) Run(ctx context.Context, flags *RootF }, }).Do() if err != nil { - return err + return wrapYouTubeWriteError(err, flags) } if outfmt.IsJSON(ctx) { - return outfmt.WriteJSON(ctx, stdoutWriter(ctx), sub) + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), map[string]any{"subscription": sub}) + } + if outfmt.IsPlain(ctx) { + u.Out().Linef("id\t%s", sub.Id) + u.Out().Linef("channel_id\t%s", channelID) + return nil } u.Out().Printf("Subscribed: %s (subscription ID: %s)\n", channelID, sub.Id) return nil @@ -818,46 +931,52 @@ func (c *YouTubeSubscriptionsUnsubscribeCmd) Run(ctx context.Context, flags *Roo // For --id we have everything needed to confirm/dry-run before any I/O. if subID != "" { - if confirmErr := dryRunAndConfirmDestructive(ctx, flags, "youtube.subscriptions.delete", map[string]any{"id": subID}, fmt.Sprintf("unsubscribe (subscription ID: %s)", subID)); confirmErr != nil { + if confirmErr := dryRunAndConfirmDestructive(ctx, flags, "youtube.subscriptions.unsubscribe", map[string]any{"id": subID}, fmt.Sprintf("unsubscribe (subscription ID: %s)", subID)); confirmErr != nil { return confirmErr } + } else if flags != nil && flags.DryRun { + return dryRunAndConfirmDestructive(ctx, flags, "youtube.subscriptions.unsubscribe", map[string]any{"channelId": channelID}, fmt.Sprintf("unsubscribe from channel %s", channelID)) } account, err := requireAccount(flags) if err != nil { return err } - svc, err := getYouTubeServiceForAccount(ctx, account) + svc, err := getYouTubeWriteServiceForAccount(ctx, account) if err != nil { return err } if channelID != "" { - if flags != nil && flags.DryRun { - // Skip live lookup in dry-run; we don't know the subscription ID yet. - return dryRunAndConfirmDestructive(ctx, flags, "youtube.subscriptions.delete", map[string]any{"channelId": channelID}, fmt.Sprintf("unsubscribe from channel %s", channelID)) - } resp, lookupErr := svc.Subscriptions.List([]string{"id"}). Mine(true). ForChannelId(channelID). MaxResults(1). Do() if lookupErr != nil { - return lookupErr + return wrapYouTubeWriteError(lookupErr, flags) } if len(resp.Items) == 0 { return fmt.Errorf("not subscribed to channel %s", channelID) } subID = resp.Items[0].Id - if confirmErr := dryRunAndConfirmDestructive(ctx, flags, "youtube.subscriptions.delete", map[string]any{"id": subID}, fmt.Sprintf("unsubscribe (subscription ID: %s)", subID)); confirmErr != nil { + if confirmErr := dryRunAndConfirmDestructive(ctx, flags, "youtube.subscriptions.unsubscribe", map[string]any{"id": subID}, fmt.Sprintf("unsubscribe (subscription ID: %s)", subID)); confirmErr != nil { return confirmErr } } if err := svc.Subscriptions.Delete(subID).Do(); err != nil { - return err + return wrapYouTubeWriteError(err, flags) } + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), map[string]any{"unsubscribed": true, "subscriptionId": subID}) + } + if outfmt.IsPlain(ctx) { + u.Out().Linef("unsubscribed\ttrue") + u.Out().Linef("subscription_id\t%s", subID) + return nil + } u.Out().Printf("Unsubscribed (subscription ID: %s)\n", subID) return nil } @@ -936,6 +1055,14 @@ func youtubeAccountSelectorPresent(flags *RootFlags) bool { } func wrapYouTubeCommentsError(err error, flags *RootFlags) error { + return wrapYouTubeForceSSLError(err, flags, "youtube comments") +} + +func wrapYouTubeWriteError(err error, flags *RootFlags) error { + return wrapYouTubeForceSSLError(err, flags, "youtube mutations") +} + +func wrapYouTubeForceSSLError(err error, flags *RootFlags, operation string) error { if err == nil { return nil } @@ -953,7 +1080,7 @@ func wrapYouTubeCommentsError(err error, flags *RootFlags) error { return err } return errfmt.NewUserFacingError( - fmt.Sprintf("youtube comments OAuth requires %s; re-authenticate with: gog auth add %s --services youtube --extra-scopes %s --force-consent", youtubeCommentsOAuthScope, account, youtubeCommentsOAuthScope), + fmt.Sprintf("%s require OAuth scope %s; re-authenticate with: gog auth add %s --services youtube --extra-scopes %s --force-consent", operation, youtubeForceSSLOAuthScope, account, youtubeForceSSLOAuthScope), err, ) } diff --git a/internal/cmd/youtube_mutations_test.go b/internal/cmd/youtube_mutations_test.go new file mode 100644 index 000000000..50bc038dd --- /dev/null +++ b/internal/cmd/youtube_mutations_test.go @@ -0,0 +1,319 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + youtube "google.golang.org/api/youtube/v3" +) + +func TestYouTubeSubscriptionsListAllUsesPageSizeAndStartCursor(t *testing.T) { + var pages []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pages = append(pages, r.URL.Query().Get("pageToken")) + if got := r.URL.Query().Get("maxResults"); got != "2" { + t.Fatalf("maxResults = %q", got) + } + switch r.URL.Query().Get("pageToken") { + case "start": + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{{"id": "SUB1"}}, + "nextPageToken": "next", + }) + case "next": + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{{"id": "SUB2"}}, + }) + default: + t.Fatalf("unexpected page token: %q", r.URL.Query().Get("pageToken")) + } + })) + defer srv.Close() + + svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService) + var stdout bytes.Buffer + ctx := withYouTubeTestServices(newCmdRuntimeJSONOutputContext(t, &stdout, io.Discard), youtubeTestServices{ + Account: fixedYouTubeTestService(svc), + }) + err := runKong(t, &YouTubeSubscriptionsListCmd{}, []string{"--all", "--max", "2", "--page", "start"}, ctx, &RootFlags{ + Account: "me@example.com", + JSON: true, + }) + if err != nil { + t.Fatalf("runKong: %v", err) + } + if strings.Join(pages, ",") != "start,next" { + t.Fatalf("pages = %v", pages) + } + var got struct { + Items []json.RawMessage `json:"items"` + NextPageToken string `json:"nextPageToken"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("json output: %v\n%s", err, stdout.String()) + } + if len(got.Items) != 2 || got.NextPageToken != "" { + t.Fatalf("output = %s", stdout.String()) + } +} + +func TestYouTubeSubscriptionsListAllEmitsEmptyArray(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{}) + })) + defer srv.Close() + + svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService) + var stdout bytes.Buffer + ctx := withYouTubeTestServices(newCmdRuntimeJSONOutputContext(t, &stdout, io.Discard), youtubeTestServices{ + Account: fixedYouTubeTestService(svc), + }) + err := runKong(t, &YouTubeSubscriptionsListCmd{}, []string{"--all"}, ctx, &RootFlags{ + Account: "me@example.com", + JSON: true, + }) + if err != nil { + t.Fatalf("runKong: %v", err) + } + var got struct { + Items []json.RawMessage `json:"items"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("json output: %v\n%s", err, stdout.String()) + } + if got.Items == nil || len(got.Items) != 0 { + t.Fatalf("expected empty array, got %s", stdout.String()) + } +} + +func TestYouTubeMutationDryRunsAreOffline(t *testing.T) { + tests := []struct { + name string + cmd interface { + Run(context.Context, *RootFlags) error + } + args []string + op string + }{ + {name: "subscribe", cmd: &YouTubeSubscriptionsSubscribeCmd{}, args: []string{"--channel-id", "UC1"}, op: "youtube.subscriptions.subscribe"}, + {name: "unsubscribe id", cmd: &YouTubeSubscriptionsUnsubscribeCmd{}, args: []string{"--id", "SUB1"}, op: "youtube.subscriptions.unsubscribe"}, + {name: "unsubscribe channel", cmd: &YouTubeSubscriptionsUnsubscribeCmd{}, args: []string{"--channel-id", "UC1"}, op: "youtube.subscriptions.unsubscribe"}, + {name: "playlist create", cmd: &YouTubePlaylistsCreateCmd{}, args: []string{"--title", "Test"}, op: "youtube.playlists.create"}, + {name: "playlist add", cmd: &YouTubePlaylistsAddCmd{}, args: []string{"--playlist-id", "PL1", "--video-id", "VID1"}, op: "youtube.playlists.add"}, + {name: "playlist remove item", cmd: &YouTubePlaylistsRemoveCmd{}, args: []string{"--item-id", "ITEM1"}, op: "youtube.playlists.remove"}, + {name: "playlist remove video", cmd: &YouTubePlaylistsRemoveCmd{}, args: []string{"--playlist-id", "PL1", "--video-id", "VID1"}, op: "youtube.playlists.remove"}, + {name: "playlist delete", cmd: &YouTubePlaylistsDeleteCmd{}, args: []string{"PL1"}, op: "youtube.playlists.delete"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stdout bytes.Buffer + ctx := withYouTubeTestServices(newCmdRuntimeJSONOutputContext(t, &stdout, io.Discard), youtubeTestServices{ + Write: unexpectedYouTubeTestService(t, "dry-run must not create a YouTube service"), + }) + err := runKong(t, tt.cmd, tt.args, ctx, &RootFlags{ + Account: "me@example.com", + DryRun: true, + JSON: true, + }) + if ExitCode(err) != 0 { + t.Fatalf("expected dry-run exit 0, got %v", err) + } + var got struct { + DryRun bool `json:"dry_run"` + Op string `json:"op"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("json output: %v\n%s", err, stdout.String()) + } + if !got.DryRun || got.Op != tt.op { + t.Fatalf("output = %s", stdout.String()) + } + }) + } +} + +func TestYouTubePlaylistCreateDefaultsPrivate(t *testing.T) { + var stdout bytes.Buffer + ctx := withYouTubeTestServices(newCmdRuntimeJSONOutputContext(t, &stdout, io.Discard), youtubeTestServices{ + Write: unexpectedYouTubeTestService(t, "dry-run must not create a YouTube service"), + }) + err := runKong(t, &YouTubePlaylistsCreateCmd{}, []string{"--title", "Test"}, ctx, &RootFlags{ + Account: "me@example.com", + DryRun: true, + JSON: true, + }) + if ExitCode(err) != 0 { + t.Fatalf("expected dry-run exit 0, got %v", err) + } + var got struct { + Request struct { + Privacy string `json:"privacy"` + } `json:"request"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("json output: %v\n%s", err, stdout.String()) + } + if got.Request.Privacy != "private" { + t.Fatalf("privacy = %q\n%s", got.Request.Privacy, stdout.String()) + } +} + +func TestYouTubePlaylistCreateAndAdd(t *testing.T) { + var createBody, addBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + switch r.URL.Path { + case "/youtube/v3/playlists": + createBody = body + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "PL1", + "snippet": map[string]any{"title": "Test"}, + "status": map[string]any{"privacyStatus": "private"}, + }) + case "/youtube/v3/playlistItems": + addBody = body + _ = json.NewEncoder(w).Encode(map[string]any{"id": "ITEM1"}) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + defer srv.Close() + + svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService) + ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), youtubeTestServices{ + Write: fixedYouTubeTestService(svc), + Account: unexpectedYouTubeTestService(t, "write command called read service"), + }) + flags := &RootFlags{Account: "me@example.com"} + if err := runKong(t, &YouTubePlaylistsCreateCmd{}, []string{"--title", " Test ", "--description", " proof ", "--privacy", "private"}, ctx, flags); err != nil { + t.Fatalf("create: %v", err) + } + if err := runKong(t, &YouTubePlaylistsAddCmd{}, []string{"--playlist-id", "PL1", "--video-id", "VID1", "--position", "0"}, ctx, flags); err != nil { + t.Fatalf("add: %v", err) + } + if !strings.Contains(string(createBody), `"title":"Test"`) || + !strings.Contains(string(createBody), `"description":"proof"`) || + !strings.Contains(string(createBody), `"privacyStatus":"private"`) { + t.Fatalf("create body = %s", createBody) + } + if !strings.Contains(string(addBody), `"playlistId":"PL1"`) || + !strings.Contains(string(addBody), `"videoId":"VID1"`) || + !strings.Contains(string(addBody), `"position":0`) { + t.Fatalf("add body = %s", addBody) + } +} + +func TestYouTubePlaylistRemoveAndDelete(t *testing.T) { + var deletedItems, deletedPlaylists []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/youtube/v3/playlistItems" && r.Method == http.MethodGet: + if r.URL.Query().Get("playlistId") != "PL1" || r.URL.Query().Get("videoId") != "VID1" { + t.Fatalf("lookup query = %s", r.URL.RawQuery) + } + _ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{{"id": "ITEM1"}}}) + case r.URL.Path == "/youtube/v3/playlistItems" && r.Method == http.MethodDelete: + deletedItems = append(deletedItems, r.URL.Query().Get("id")) + w.WriteHeader(http.StatusNoContent) + case r.URL.Path == "/youtube/v3/playlists" && r.Method == http.MethodDelete: + deletedPlaylists = append(deletedPlaylists, r.URL.Query().Get("id")) + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + } + })) + defer srv.Close() + + svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService) + ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), youtubeTestServices{ + Write: fixedYouTubeTestService(svc), + }) + flags := &RootFlags{Account: "me@example.com", Force: true} + if err := runKong(t, &YouTubePlaylistsRemoveCmd{}, []string{"--playlist-id", "PL1", "--video-id", "VID1"}, ctx, flags); err != nil { + t.Fatalf("remove by video: %v", err) + } + if err := runKong(t, &YouTubePlaylistsRemoveCmd{}, []string{"--item-id", "ITEM2"}, ctx, flags); err != nil { + t.Fatalf("remove by item: %v", err) + } + if err := runKong(t, &YouTubePlaylistsDeleteCmd{}, []string{"PL1"}, ctx, flags); err != nil { + t.Fatalf("delete playlist: %v", err) + } + if strings.Join(deletedItems, ",") != "ITEM1,ITEM2" { + t.Fatalf("deleted items = %v", deletedItems) + } + if strings.Join(deletedPlaylists, ",") != "PL1" { + t.Fatalf("deleted playlists = %v", deletedPlaylists) + } +} + +func TestYouTubeMutationValidationBeforeService(t *testing.T) { + ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), youtubeTestServices{ + Write: unexpectedYouTubeTestService(t, "invalid command must not create service"), + }) + flags := &RootFlags{Account: "me@example.com", Force: true} + tests := []struct { + name string + cmd interface { + Run(context.Context, *RootFlags) error + } + args []string + want string + }{ + {name: "negative position", cmd: &YouTubePlaylistsAddCmd{}, args: []string{"--playlist-id", "PL1", "--video-id", "VID1", "--position=-2"}, want: "--position must be >= 0"}, + {name: "remove missing selector", cmd: &YouTubePlaylistsRemoveCmd{}, want: "set --video-id or --item-id"}, + {name: "remove both selectors", cmd: &YouTubePlaylistsRemoveCmd{}, args: []string{"--video-id", "VID1", "--item-id", "ITEM1"}, want: "either --video-id or --item-id"}, + {name: "remove missing playlist", cmd: &YouTubePlaylistsRemoveCmd{}, args: []string{"--video-id", "VID1"}, want: "--playlist-id is required"}, + {name: "delete blank", cmd: &YouTubePlaylistsDeleteCmd{}, args: []string{" "}, want: "playlist-id is required"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := runKong(t, tt.cmd, tt.args, ctx, flags) + if err == nil || ExitCode(err) != 2 || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("expected usage error containing %q, got %v", tt.want, err) + } + }) + } +} + +func TestYouTubeReadCommandsNeverCallWriteService(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{}}) + })) + defer srv.Close() + + svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService) + ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), youtubeTestServices{ + Account: fixedYouTubeTestService(svc), + Write: unexpectedYouTubeTestService(t, "read command called write service"), + }) + flags := &RootFlags{Account: "me@example.com"} + if err := runKong(t, &YouTubeSubscriptionsListCmd{}, []string{"--max", "1"}, ctx, flags); err != nil { + t.Fatalf("subscriptions list: %v", err) + } + if err := runKong(t, &YouTubePlaylistsListCmd{}, []string{"--mine", "--max", "1"}, ctx, flags); err != nil { + t.Fatalf("playlists list: %v", err) + } +} + +func TestWrapYouTubeWriteError(t *testing.T) { + original := errors.New("ACCESS_TOKEN_SCOPE_INSUFFICIENT") + err := wrapYouTubeWriteError(original, &RootFlags{Account: "me@example.com"}) + if !strings.Contains(err.Error(), youtubeForceSSLOAuthScope) || + !strings.Contains(err.Error(), "gog auth add me@example.com") || + !errors.Is(err, original) { + t.Fatalf("wrapped error = %v", err) + } + if got := wrapYouTubeWriteError(errors.New("quota exceeded"), &RootFlags{Account: "me@example.com"}); strings.Contains(got.Error(), "auth add") { + t.Fatalf("unrelated error was wrapped: %v", got) + } +} diff --git a/internal/cmd/youtube_services.go b/internal/cmd/youtube_services.go index 470c013ab..1e56d113f 100644 --- a/internal/cmd/youtube_services.go +++ b/internal/cmd/youtube_services.go @@ -46,3 +46,10 @@ func getYouTubeCommentsServiceForAccount(ctx context.Context, account string) (* } return googleapi.NewYouTubeCommentsForAccount(ctx, account) } + +func getYouTubeWriteServiceForAccount(ctx context.Context, account string) (*youtube.Service, error) { + if runtime, ok := app.FromContext(ctx); ok && runtime.Services.YouTubeWrite != nil { + return runtime.Services.YouTubeWrite(ctx, account) + } + return googleapi.NewYouTubeWriteForAccount(ctx, account) +} diff --git a/internal/cmd/youtube_test.go b/internal/cmd/youtube_test.go index 4e7b9b20b..7234e5db0 100644 --- a/internal/cmd/youtube_test.go +++ b/internal/cmd/youtube_test.go @@ -584,7 +584,7 @@ func TestYouTubeSubscriptionsSubscribe(t *testing.T) { svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService) var stdout bytes.Buffer ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, &stdout, io.Discard), youtubeTestServices{ - Account: fixedYouTubeTestService(svc), + Write: fixedYouTubeTestService(svc), }) err := runKong(t, &YouTubeSubscriptionsSubscribeCmd{}, []string{"--channel-id", "UCnew"}, ctx, &RootFlags{Account: "me@example.com"}) if err != nil { @@ -612,7 +612,7 @@ func TestYouTubeSubscriptionsUnsubscribeByID(t *testing.T) { svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService) ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), youtubeTestServices{ - Account: fixedYouTubeTestService(svc), + Write: fixedYouTubeTestService(svc), }) err := runKong(t, &YouTubeSubscriptionsUnsubscribeCmd{}, []string{"--id", "SUB123"}, ctx, &RootFlags{Account: "me@example.com", Force: true}) if err != nil { @@ -646,7 +646,7 @@ func TestYouTubeSubscriptionsUnsubscribeByChannelID(t *testing.T) { svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService) ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), youtubeTestServices{ - Account: fixedYouTubeTestService(svc), + Write: fixedYouTubeTestService(svc), }) err := runKong(t, &YouTubeSubscriptionsUnsubscribeCmd{}, []string{"--channel-id", "UCcool"}, ctx, &RootFlags{Account: "me@example.com", Force: true}) if err != nil { @@ -669,7 +669,7 @@ func TestYouTubeSubscriptionsUnsubscribeChannelNotFound(t *testing.T) { svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService) ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), youtubeTestServices{ - Account: fixedYouTubeTestService(svc), + Write: fixedYouTubeTestService(svc), }) err := runKong(t, &YouTubeSubscriptionsUnsubscribeCmd{}, []string{"--channel-id", "UCmissing"}, ctx, &RootFlags{Account: "me@example.com", Force: true}) if err == nil || !strings.Contains(err.Error(), "not subscribed") { @@ -679,7 +679,7 @@ func TestYouTubeSubscriptionsUnsubscribeChannelNotFound(t *testing.T) { func TestYouTubeSubscriptionsUnsubscribeValidation(t *testing.T) { ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), youtubeTestServices{ - Account: unexpectedYouTubeTestService(t, "should not reach service with missing args"), + Write: unexpectedYouTubeTestService(t, "should not reach service with missing args"), }) flags := &RootFlags{Account: "me@example.com", Force: true} tests := []struct { @@ -712,6 +712,7 @@ func TestYouTubeValidationRejectsBlankSelectorsBeforeService(t *testing.T) { ctx := withYouTubeTestServices(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), youtubeTestServices{ Account: unexpectedYouTubeTestService(t, "expected validation to fail before OAuth YouTube service creation"), Comments: unexpectedYouTubeTestService(t, "expected validation to fail before OAuth YouTube comments service creation"), + Write: unexpectedYouTubeTestService(t, "expected validation to fail before OAuth YouTube write service creation"), APIKey: unexpectedYouTubeTestService(t, "expected validation to fail before API-key YouTube service creation"), }) flags := &RootFlags{Account: "me@example.com"} diff --git a/internal/cmd/youtube_test_helpers_test.go b/internal/cmd/youtube_test_helpers_test.go index 95a6cf974..2706f42a6 100644 --- a/internal/cmd/youtube_test_helpers_test.go +++ b/internal/cmd/youtube_test_helpers_test.go @@ -14,6 +14,7 @@ type youtubeTestServices struct { APIKey app.YouTubeServiceFactory Account app.YouTubeServiceFactory Comments app.YouTubeServiceFactory + Write app.YouTubeServiceFactory } func fixedYouTubeTestService(svc *youtube.Service) app.YouTubeServiceFactory { @@ -35,5 +36,6 @@ func withYouTubeTestServices(ctx context.Context, services youtubeTestServices) runtime.Services.YouTubeAPIKey = services.APIKey runtime.Services.YouTubeAccount = services.Account runtime.Services.YouTubeComments = services.Comments + runtime.Services.YouTubeWrite = services.Write }) } diff --git a/internal/googleapi/youtube.go b/internal/googleapi/youtube.go index cb0900840..9d3b4819c 100644 --- a/internal/googleapi/youtube.go +++ b/internal/googleapi/youtube.go @@ -78,3 +78,20 @@ func NewYouTubeCommentsForAccount(ctx context.Context, email string) (*youtube.S return svc, nil } + +// NewYouTubeWriteForAccount creates a YouTube client for account mutations. +// youtube.force-ssl covers subscription, playlist, and comment operations while +// the general account client remains limited to youtube.readonly. +func NewYouTubeWriteForAccount(ctx context.Context, email string) (*youtube.Service, error) { + opts, err := optionsForAccountScopes(ctx, string(googleauth.ServiceYouTube), email, []string{scopeYouTubeForceSSL}) + if err != nil { + return nil, fmt.Errorf("youtube write OAuth options: %w", err) + } + + svc, err := youtube.NewService(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("youtube write service for account: %w", err) + } + + return svc, nil +} diff --git a/internal/googleapi/youtube_test.go b/internal/googleapi/youtube_test.go index dbec44272..4dbb40660 100644 --- a/internal/googleapi/youtube_test.go +++ b/internal/googleapi/youtube_test.go @@ -5,8 +5,29 @@ import ( "net/http" "net/http/httptest" "testing" + + "github.com/steipete/gogcli/internal/googleauth" ) +func TestYouTubeScopeContract(t *testing.T) { + readScopes, err := googleauth.Scopes(googleauth.ServiceYouTube) + if err != nil { + t.Fatalf("Scopes(ServiceYouTube): %v", err) + } + + if len(readScopes) != 1 || readScopes[0] != "https://www.googleapis.com/auth/youtube.readonly" { + t.Fatalf("read scopes = %v", readScopes) + } + + if scopeYouTubeForceSSL != "https://www.googleapis.com/auth/youtube.force-ssl" { + t.Fatalf("write scope = %q", scopeYouTubeForceSSL) + } + + if scopeYouTubeForceSSL == readScopes[0] { + t.Fatal("write scope must remain separate from read scope") + } +} + func TestNewYouTubeAPIKeyHTTPClientAddsKeyWithRetryTransport(t *testing.T) { client, err := newYouTubeAPIKeyHTTPClient(context.Background(), "test-key") if err != nil { From 4344f57f888325f8693ad64c9fcabb9c900f03b3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 13 Jun 2026 01:21:51 +0100 Subject: [PATCH 10/10] docs(youtube): add mutation workflow guide --- README.md | 3 +- docs/index.md | 1 + docs/youtube.md | 117 ++++++++++++++++++++++++++++++++++++ scripts/build-docs-site.mjs | 1 + 4 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 docs/youtube.md diff --git a/README.md b/README.md index 47e729230..9d8962fe4 100644 --- a/README.md +++ b/README.md @@ -355,7 +355,8 @@ gog forms raw --pretty ### YouTube -Docs: [`gog youtube`](docs/commands/gog-youtube.md), +Docs: [YouTube workflows](docs/youtube.md), +[`gog youtube`](docs/commands/gog-youtube.md), [`youtube channels`](docs/commands/gog-youtube-channels.md), [`youtube videos`](docs/commands/gog-youtube-videos.md), [`youtube activities`](docs/commands/gog-youtube-activities.md), diff --git a/docs/index.md b/docs/index.md index 10d74dda8..21cac84df 100644 --- a/docs/index.md +++ b/docs/index.md @@ -55,6 +55,7 @@ gog slides create-from-markdown "Weekly update" --content-file slides.md - **Managing Workspace.** [Workspace Admin](workspace-admin.md) covers user creation, cleanup, organizational units, and group administration. - **Backing up an account.** [Backup](backup.md) before pointing `gog backup push` at a busy mailbox. - **Selecting private Photos media.** [Photos Picker](photos-picker.md) keeps access limited to items the user explicitly chooses. +- **Managing YouTube.** [YouTube](youtube.md) covers API-key reads, account OAuth, subscriptions, playlists, and mutation safety. - **Grouping Docs edits atomically.** [Google Docs request batches](docs-batch.md) covers persisted, revision-locked request queues and explicit recovery modes. - **Verifying real API behavior.** [Live testing](live-testing.md) covers the dedicated-account smoke suite, cleanup, retries, and optional infrastructure. - **Looking up a flag.** The [Command Index](commands/) has a generated page for every subcommand. diff --git a/docs/youtube.md b/docs/youtube.md new file mode 100644 index 000000000..a09456844 --- /dev/null +++ b/docs/youtube.md @@ -0,0 +1,117 @@ +--- +title: YouTube +description: "Read YouTube data and manage subscriptions and playlists with gog." +--- + +# YouTube + +`gog youtube` (alias `gog yt`) reads public YouTube data with an API key or +uses account OAuth for private reads and mutations. + +## Configure access + +For public channel, video, activity, playlist, comment, and search reads, enable +YouTube Data API v3 and store an API key: + +```bash +gog config set youtube_api_key YOUR_API_KEY +gog yt videos list --chart mostPopular --region US --max 5 +``` + +Account reads use the default `youtube.readonly` scope: + +```bash +gog auth add you@gmail.com --services youtube +gog yt activities list --mine --account you@gmail.com +``` + +Subscription and playlist mutations require the explicit +`youtube.force-ssl` extra scope: + +```bash +gog auth add you@gmail.com --services youtube \ + --extra-scopes https://www.googleapis.com/auth/youtube.force-ssl \ + --force-consent +``` + +The account must already have a YouTube channel. If the API returns +`youtubeSignupRequired`, initialize the channel once at +[youtube.com](https://www.youtube.com/) and retry. + +## Manage subscriptions + +List one page or fetch every page: + +```bash +gog yt subscriptions list --max 50 --account you@gmail.com +gog yt subscriptions list --all --account you@gmail.com --json +``` + +Subscribe with a channel ID: + +```bash +gog yt subscriptions subscribe \ + --channel-id UC_x5XG1OV2P6uZZ5FSM9Ttw \ + --account you@gmail.com +``` + +Unsubscribe using either the subscription ID returned by `subscriptions list` +or a channel ID. Channel-ID removal performs the subscription lookup for you: + +```bash +gog yt subscriptions unsubscribe --id SUBSCRIPTION_ID \ + --account you@gmail.com --force +gog yt subscriptions unsubscribe --channel-id UC_x5XG1OV2P6uZZ5FSM9Ttw \ + --account you@gmail.com --force +``` + +## Manage playlists + +New playlists default to private. Set `--privacy unlisted` or +`--privacy public` only when broader visibility is intended. + +```bash +gog yt playlists create --title "Research" \ + --description "Videos to review" \ + --account you@gmail.com --json + +gog yt playlists add --playlist-id PLAYLIST_ID --video-id VIDEO_ID \ + --position 0 --account you@gmail.com +``` + +Remove a known playlist item directly, or let `gog` find the item by playlist +and video ID: + +```bash +gog yt playlists remove --item-id PLAYLIST_ITEM_ID \ + --account you@gmail.com --force +gog yt playlists remove --playlist-id PLAYLIST_ID --video-id VIDEO_ID \ + --account you@gmail.com --force +``` + +Delete a playlist: + +```bash +gog yt playlists delete PLAYLIST_ID --account you@gmail.com --force +``` + +## Automation and safety + +Every subscription and playlist mutation supports `--dry-run`. Dry runs do not +create an API service or make a network request: + +```bash +gog yt playlists add --playlist-id PLAYLIST_ID --video-id VIDEO_ID \ + --account you@gmail.com --dry-run --json +``` + +Unsubscribe, playlist-item removal, and playlist deletion prompt before the +mutation. Use `--force` only after checking the target, or combine +`--no-input --force` in deliberate automation. + +Use `--json` for structured output or `--plain` for stable TSV. Human progress, +prompts, and warnings remain on stderr. + +See the generated references for +[`youtube subscriptions`](commands/gog-youtube-subscriptions.md) and +[`youtube playlists`](commands/gog-youtube-playlists.md) for every flag. diff --git a/scripts/build-docs-site.mjs b/scripts/build-docs-site.mjs index 78b96f011..f5ab5983e 100755 --- a/scripts/build-docs-site.mjs +++ b/scripts/build-docs-site.mjs @@ -23,6 +23,7 @@ const sections = [ ["Gmail", ["gmail-workflows.md", "gmail-autoreply.md", "watch.md", "email-tracking.md", "email-tracking-worker.md"]], ["Drive & Files", ["drive-audits.md", "raw-api.md", "raw-audit.md"]], ["Photos", ["photos-picker.md"]], + ["YouTube", ["youtube.md"]], ["Docs, Sheets, Slides", ["docs-editing.md", "docs-batch.md", "sedmat.md", "sheets-batch-update.md", "sheets-tables.md", "sheets-formatting.md", "slides-markdown.md", "slides-template-replacement.md"]], ["Contacts", ["contacts-dedupe.md", "contacts-json-update.md"]], ["Backup", ["backup.md"]],