From df860b3d853545b5afd2e0016c533abec00021ff Mon Sep 17 00:00:00 2001 From: Kuldip Satpute Date: Wed, 18 Feb 2026 13:08:04 +0530 Subject: [PATCH] feat(youtube): add YouTube Data API v3 support - Introduced `youtube` command group for managing YouTube activities, videos, playlists, comments, and channels. - Added configuration options for YouTube API key via `youtube_api_key` or `GOG_YOUTUBE_API_KEY`. - Updated documentation to reflect new YouTube features and commands. - Enhanced CLI help descriptions to include YouTube services. This commit expands the functionality of the CLI to include comprehensive support for YouTube, allowing users to interact with various YouTube data endpoints. --- CHANGELOG.md | 1 + README.md | 48 +++- internal/cmd/contacts_crud.go | 4 +- internal/cmd/root.go | 5 +- internal/cmd/youtube.go | 415 ++++++++++++++++++++++++++++ internal/cmd/youtube_services.go | 39 +++ internal/config/config.go | 1 + internal/config/keys.go | 22 ++ internal/googleapi/youtube.go | 56 ++++ internal/googleauth/service.go | 14 + internal/googleauth/service_test.go | 10 +- 11 files changed, 604 insertions(+), 11 deletions(-) create mode 100644 internal/cmd/youtube.go create mode 100644 internal/cmd/youtube_services.go create mode 100644 internal/googleapi/youtube.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bfb9e66..e009820b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.12.0 - Unreleased ### Added +- YouTube: add `youtube` (alias `yt`) command group for YouTube Data API v3 — list activities, videos, playlists, comment threads, and channels; API key via config `youtube_api_key` or `GOG_YOUTUBE_API_KEY`; OAuth for “mine” with `gog auth add ... --services youtube`. - Sheets: add `sheets insert` to insert rows/columns into a sheet. (#203) — thanks @andybergon. - Gmail: add `watch serve --history-types` filtering (`messageAdded|messageDeleted|labelAdded|labelRemoved`) and include `deletedMessageIds` in webhook payloads. (#168) — thanks @salmonumbrella. - Contacts: support `--org`, `--title`, `--url`, `--note`, and `--custom` on create/update; include custom fields in get output with deterministic ordering. (#199) — thanks @phuctm97. diff --git a/README.md b/README.md index 43052fcb..4b4eec1c 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ ![GitHub Repo Banner](https://ghrb.waren.build/banner?header=gogcli%F0%9F%A7%AD&subheader=Google+in+your+terminal&bg=f3f4f6&color=1f2937&support=true) -Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Slides, Sheets, Forms, Apps Script, Contacts, Tasks, People, Groups (Workspace), and Keep (Workspace-only). JSON-first output, multiple accounts, and least-privilege auth built in. +Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Slides, Sheets, Forms, Apps Script, Contacts, Tasks, People, Groups (Workspace), Keep (Workspace-only), and YouTube. JSON-first output, multiple accounts, and least-privilege auth built in. ## Features @@ -22,6 +22,7 @@ Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Sli - **People** - access profile information - **Keep (Workspace only)** - list/get/search notes and download attachments (service account + domain-wide delegation) - **Groups** - list groups you belong to, view group members (Google Workspace) +- **YouTube** - list activities, videos, playlists, comment threads, and channels (API key or OAuth for “mine”) - **Local time** - quick local/UTC time display for scripts and agents - **Multiple accounts** - manage multiple Google accounts simultaneously (with aliases) - **Command allowlist** - restrict top-level commands for sandboxed/agent runs @@ -87,6 +88,7 @@ Before adding an account, create OAuth2 credentials from Google Cloud Console: - Google Forms API: https://console.cloud.google.com/apis/api/forms.googleapis.com - Apps Script API: https://console.cloud.google.com/apis/api/script.googleapis.com - Cloud Identity API (Groups): https://console.cloud.google.com/apis/api/cloudidentity.googleapis.com + - YouTube Data API v3 (YouTube): https://console.cloud.google.com/apis/api/youtube.googleapis.com 3. Configure OAuth consent screen: https://console.cloud.google.com/auth/branding 4. If your app is in "Testing", add test users: https://console.cloud.google.com/auth/audience 5. Create OAuth client: @@ -356,6 +358,7 @@ Service scope matrix (auto-generated; run `go run scripts/gen-auth-services-md.g | appscript | yes | Apps Script API | `https://www.googleapis.com/auth/script.projects`
`https://www.googleapis.com/auth/script.deployments`
`https://www.googleapis.com/auth/script.processes` | | | groups | no | Cloud Identity API | `https://www.googleapis.com/auth/cloud-identity.groups.readonly` | Workspace only | | keep | no | Keep API | `https://www.googleapis.com/auth/keep.readonly` | Workspace only; service account (domain-wide delegation) | +| youtube | yes | YouTube Data API v3 | `https://www.googleapis.com/auth/youtube.readonly` | Most read operations also work with API key only (config youtube_api_key or GOG_YOUTUBE_API_KEY) | ### Service Accounts (Workspace only) @@ -416,7 +419,8 @@ gog keep get --account you@yourdomain.com - `GOG_PLAIN` - Default plain output - `GOG_COLOR` - Color mode: `auto` (default), `always`, or `never` - `GOG_TIMEZONE` - Default output timezone for Calendar/Gmail (IANA name, `UTC`, or `local`) -- `GOG_ENABLE_COMMANDS` - Comma-separated allowlist of top-level commands (e.g., `calendar,tasks`) +- `GOG_ENABLE_COMMANDS` - Comma-separated allowlist of top-level commands (e.g., `calendar,tasks,youtube`) +- `GOG_YOUTUBE_API_KEY` - YouTube Data API key (overrides config `youtube_api_key` when set) ### Config File (JSON5) @@ -436,6 +440,8 @@ Example (JSON5 supports comments and trailing commas): keyring_backend: "file", // Default output timezone for Calendar/Gmail (IANA, UTC, or local) default_timezone: "UTC", + // YouTube Data API key (optional; or set GOG_YOUTUBE_API_KEY) + // youtube_api_key: "YOUR_KEY", // Optional account aliases account_aliases: { work: "work@company.com", @@ -951,6 +957,43 @@ gog tasks clear # See docs/dates.md for all supported date/time input formats across commands. ``` +### YouTube + +YouTube Data API commands use an **API key** for public data (by channel/video/playlist ID). For “mine” (your channel, playlists, activities), use OAuth with `-a` and `gog auth add ... --services youtube`. + +Set an API key (create one in [Google Cloud Console](https://console.cloud.google.com/apis/credentials) after enabling YouTube Data API v3): + +```bash +gog config set youtube_api_key YOUR_API_KEY +# or +export GOG_YOUTUBE_API_KEY=YOUR_API_KEY +``` + +```bash +# Activities (channel feed) +gog yt activities list --channel-id UC_x5XG1OV2P6uZZ5FSM9Ttw --max 10 +gog yt activities list --mine -a you@gmail.com # OAuth + +# Videos +gog yt videos list --id dQw4w9WgXcQ +gog yt videos list --chart mostPopular --region US --max 5 + +# Playlists +gog yt playlists list --channel-id UC_x5XG1OV2P6uZZ5FSM9Ttw +gog yt playlists list --mine -a you@gmail.com # OAuth + +# Comment threads +gog yt comments list --video-id VIDEO_ID --max 20 +gog yt comments list --channel-id CHANNEL_ID + +# Channels +gog yt channels list --id UC_x5XG1OV2P6uZZ5FSM9Ttw +gog yt channels list --mine -a you@gmail.com # OAuth + +# JSON output +gog yt channels list --id UC_x5XG1OV2P6uZZ5FSM9Ttw --json +``` + ### Sheets ```bash @@ -1508,6 +1551,7 @@ MIT - [Google Drive API Documentation](https://developers.google.com/drive) - [Google People API Documentation](https://developers.google.com/people) - [Google Tasks API Documentation](https://developers.google.com/tasks) +- [YouTube Data API Documentation](https://developers.google.com/youtube/v3) - [Google Sheets API Documentation](https://developers.google.com/sheets) - [Cloud Identity API Documentation](https://cloud.google.com/identity/docs/reference/rest) diff --git a/internal/cmd/contacts_crud.go b/internal/cmd/contacts_crud.go index 31cdc298..0262f92d 100644 --- a/internal/cmd/contacts_crud.go +++ b/internal/cmd/contacts_crud.go @@ -247,7 +247,7 @@ func contactsURLs(values []string) []*people.Url { return out } -func contactsApplyPersonName(person *people.Person, givenSet bool, given, familySet bool, family string) { +func contactsApplyPersonName(person *people.Person, givenSet bool, given string, familySet bool, family string) { curGiven := "" curFamily := "" if len(person.Names) > 0 && person.Names[0] != nil { @@ -263,7 +263,7 @@ func contactsApplyPersonName(person *people.Person, givenSet bool, given, family person.Names = []*people.Name{{GivenName: curGiven, FamilyName: curFamily}} } -func contactsApplyPersonOrganization(person *people.Person, orgSet bool, org, titleSet bool, title string) { +func contactsApplyPersonOrganization(person *people.Person, orgSet bool, org string, titleSet bool, title string) { curOrg := "" curTitle := "" if len(person.Organizations) > 0 && person.Organizations[0] != nil { diff --git a/internal/cmd/root.go b/internal/cmd/root.go index d301db26..0eeec8c2 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -29,7 +29,7 @@ const ( type RootFlags struct { Color string `help:"Color output: auto|always|never" default:"${color}"` - Account string `help:"Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets/forms/appscript)" aliases:"acct" short:"a"` + Account string `help:"Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets/forms/appscript/youtube)" aliases:"acct" short:"a"` Client string `help:"OAuth client name (selects stored credentials + token bucket)" default:"${client}"` EnableCommands string `help:"Comma-separated list of enabled top-level commands (restricts CLI)" default:"${enabled_commands}"` JSON bool `help:"Output JSON to stdout (best for scripting)" default:"${json}" aliases:"machine" short:"j"` @@ -77,6 +77,7 @@ type CLI struct { Sheets SheetsCmd `cmd:"" aliases:"sheet" help:"Google Sheets"` Forms FormsCmd `cmd:"" aliases:"form" help:"Google Forms"` AppScript AppScriptCmd `cmd:"" name:"appscript" aliases:"script,apps-script" help:"Google Apps Script"` + YouTube YouTubeCmd `cmd:"" aliases:"yt" help:"YouTube Data API (activities, videos, playlists, comments, channels)"` Config ConfigCmd `cmd:"" help:"Manage configuration"` ExitCodes AgentExitCodesCmd `cmd:"" name:"exit-codes" aliases:"exitcodes" help:"Print stable exit codes (alias for 'agent exit-codes')"` Agent AgentCmd `cmd:"" help:"Agent-friendly helpers"` @@ -324,7 +325,7 @@ func newParser(description string) (*kong.Kong, *CLI, error) { } func baseDescription() string { - return "Google CLI for Gmail/Calendar/Chat/Classroom/Drive/Contacts/Tasks/Sheets/Docs/Slides/People/Forms/App Script" + return "Google CLI for Gmail/Calendar/Chat/Classroom/Drive/Contacts/Tasks/Sheets/Docs/Slides/People/Forms/App Script/YouTube" } func helpDescription() string { diff --git a/internal/cmd/youtube.go b/internal/cmd/youtube.go new file mode 100644 index 00000000..19141dab --- /dev/null +++ b/internal/cmd/youtube.go @@ -0,0 +1,415 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + youtube "google.golang.org/api/youtube/v3" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +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"` +} + +type YouTubeActivitiesCmd struct { + List YouTubeActivitiesListCmd `cmd:"" name:"list" aliases:"ls" help:"List activities for a channel (or authenticated user)"` +} + +type YouTubeActivitiesListCmd struct { + ChannelID string `name:"channel-id" help:"Channel ID (use with API key)"` + Mine bool `name:"mine" help:"Use authenticated user's channel (requires -a account)"` + Max int64 `name:"max" aliases:"limit" help:"Max results" default:"25"` + Page string `name:"page" help:"Page token"` +} + +func (c *YouTubeActivitiesListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + if c.ChannelID == "" && !c.Mine { + return usage("set --channel-id ID or --mine (--mine requires -a account)") + } + if c.ChannelID != "" && c.Mine { + return usage("use either --channel-id or --mine, not both") + } + + var svc *youtube.Service + var err error + if c.Mine { + account, accErr := requireAccount(flags) + if accErr != nil { + return accErr + } + svc, err = getYouTubeServiceForAccount(ctx, account) + } else { + svc, err = getYouTubeServiceWithAPIKey(ctx) + } + if err != nil { + return err + } + + call := svc.Activities.List([]string{"snippet", "contentDetails"}). + MaxResults(c.Max). + PageToken(c.Page) + if c.ChannelID != "" { + call = call.ChannelId(c.ChannelID) + } else { + call = call.Mine(true) + } + resp, err := call.Do() + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "items": resp.Items, + "nextPageToken": resp.NextPageToken, + }) + } + fmt.Println(resp) + if len(resp.Items) == 0 { + u.Err().Println("No activities") + return nil + } + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "KIND\tVIDEO_ID\tTITLE\tPUBLISHED_AT") + for _, a := range resp.Items { + vidID := "" + if a.ContentDetails != nil && a.ContentDetails.Upload != nil { + vidID = a.ContentDetails.Upload.VideoId + } + title := "" + if a.Snippet != nil { + title = a.Snippet.Title + } + pubAt := "" + if a.Snippet != nil && a.Snippet.PublishedAt != "" { + pubAt = a.Snippet.PublishedAt + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", a.Kind, sanitizeTab(vidID), sanitizeTab(title), sanitizeTab(pubAt)) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type YouTubeVideosCmd struct { + List YouTubeVideosListCmd `cmd:"" name:"list" aliases:"ls" help:"List videos by ID or chart"` +} + +type YouTubeVideosListCmd struct { + ID string `name:"id" help:"Comma-separated video IDs"` + Chart string `name:"chart" help:"Chart: mostPopular (regionCode required)"` + Region string `name:"region" help:"Region code (e.g. US) for chart"` + Max int64 `name:"max" aliases:"limit" help:"Max results" default:"25"` + Page string `name:"page" help:"Page token"` +} + +func (c *YouTubeVideosListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + if c.ID == "" && c.Chart == "" { + return usage("set --id VIDEO_IDS or --chart mostPopular") + } + if c.ID != "" && c.Chart != "" { + return usage("use either --id or --chart, not both") + } + if c.Chart != "" && c.Chart != "mostPopular" { + return usage("--chart must be mostPopular") + } + if c.Chart == "mostPopular" && c.Region == "" { + return usage("--chart mostPopular requires --region (e.g. US)") + } + + svc, err := getYouTubeServiceWithAPIKey(ctx) + if err != nil { + return err + } + + call := svc.Videos.List([]string{"snippet", "contentDetails", "statistics"}). + MaxResults(c.Max). + PageToken(c.Page) + if c.ID != "" { + call = call.Id(strings.Split(c.ID, ",")...) + } else { + call = call.Chart(c.Chart).RegionCode(c.Region) + } + resp, err := call.Do() + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "items": resp.Items, + "nextPageToken": resp.NextPageToken, + }) + } + if len(resp.Items) == 0 { + u.Err().Println("No videos") + return nil + } + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ID\tTITLE\tCHANNEL\tVIEWS\tPUBLISHED_AT") + for _, v := range resp.Items { + title := "" + ch := "" + views := "" + pubAt := "" + if v.Snippet != nil { + title = v.Snippet.Title + ch = v.Snippet.ChannelTitle + pubAt = v.Snippet.PublishedAt + } + if v.Statistics != nil { + views = fmt.Sprintf("%d", v.Statistics.ViewCount) + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", v.Id, sanitizeTab(title), sanitizeTab(ch), views, sanitizeTab(pubAt)) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type YouTubePlaylistsCmd struct { + List YouTubePlaylistsListCmd `cmd:"" name:"list" aliases:"ls" help:"List playlists by channel or authenticated user"` +} + +type YouTubePlaylistsListCmd struct { + ChannelID string `name:"channel-id" help:"Channel ID (use with API key)"` + Mine bool `name:"mine" help:"Use authenticated user (requires -a account)"` + Max int64 `name:"max" aliases:"limit" help:"Max results" default:"25"` + Page string `name:"page" help:"Page token"` +} + +func (c *YouTubePlaylistsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + if c.ChannelID == "" && !c.Mine { + return usage("set --channel-id ID or --mine (--mine requires -a account)") + } + if c.ChannelID != "" && c.Mine { + return usage("use either --channel-id or --mine, not both") + } + + var svc *youtube.Service + var err error + if c.Mine { + account, accErr := requireAccount(flags) + if accErr != nil { + return accErr + } + svc, err = getYouTubeServiceForAccount(ctx, account) + } else { + svc, err = getYouTubeServiceWithAPIKey(ctx) + } + if err != nil { + return err + } + + call := svc.Playlists.List([]string{"snippet", "contentDetails"}). + MaxResults(c.Max). + PageToken(c.Page) + if c.ChannelID != "" { + call = call.ChannelId(c.ChannelID) + } else { + call = call.Mine(true) + } + resp, err := call.Do() + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "items": resp.Items, + "nextPageToken": resp.NextPageToken, + }) + } + if len(resp.Items) == 0 { + u.Err().Println("No playlists") + return nil + } + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ID\tTITLE\tCHANNEL\tVIDEO_COUNT\tPUBLISHED_AT") + for _, p := range resp.Items { + title := "" + ch := "" + pubAt := "" + count := int64(0) + if p.Snippet != nil { + title = p.Snippet.Title + ch = p.Snippet.ChannelTitle + pubAt = p.Snippet.PublishedAt + } + if p.ContentDetails != nil { + count = p.ContentDetails.ItemCount + } + fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\n", p.Id, sanitizeTab(title), sanitizeTab(ch), count, sanitizeTab(pubAt)) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type YouTubeCommentsCmd struct { + List YouTubeCommentsListCmd `cmd:"" name:"list" aliases:"ls" help:"List comment threads for a video or channel"` +} + +type YouTubeCommentsListCmd struct { + VideoID string `name:"video-id" help:"Video ID (list top-level comments for this video)"` + ChannelID string `name:"channel-id" help:"Channel ID (list comments that mention the channel)"` + Max int64 `name:"max" aliases:"limit" help:"Max results" default:"25"` + Page string `name:"page" help:"Page token"` +} + +func (c *YouTubeCommentsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + if c.VideoID == "" && c.ChannelID == "" { + return usage("set --video-id ID or --channel-id ID") + } + if c.VideoID != "" && c.ChannelID != "" { + return usage("use either --video-id or --channel-id, not both") + } + + svc, err := getYouTubeServiceWithAPIKey(ctx) + if err != nil { + return err + } + + call := svc.CommentThreads.List([]string{"snippet"}). + MaxResults(c.Max). + PageToken(c.Page) + if c.VideoID != "" { + call = call.VideoId(c.VideoID) + } else { + call = call.ChannelId(c.ChannelID) + } + resp, err := call.Do() + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "items": resp.Items, + "nextPageToken": resp.NextPageToken, + }) + } + if len(resp.Items) == 0 { + u.Err().Println("No comment threads") + return nil + } + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ID\tAUTHOR\tTEXT\tLIKE_COUNT\tPUBLISHED_AT") + for _, t := range resp.Items { + id := t.Id + author := "" + text := "" + likes := int64(0) + pubAt := "" + if t.Snippet != nil && t.Snippet.TopLevelComment != nil && t.Snippet.TopLevelComment.Snippet != nil { + s := t.Snippet.TopLevelComment.Snippet + author = s.AuthorDisplayName + text = s.TextDisplay + likes = s.LikeCount + pubAt = s.PublishedAt + } + text = strings.ReplaceAll(strings.TrimSpace(text), "\n", " ") + if len(text) > 60 { + text = text[:57] + "..." + } + fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\n", id, sanitizeTab(author), sanitizeTab(text), likes, sanitizeTab(pubAt)) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type YouTubeChannelsCmd struct { + List YouTubeChannelsListCmd `cmd:"" name:"list" aliases:"ls" help:"List channels by ID or authenticated user"` +} + +type YouTubeChannelsListCmd struct { + ID string `name:"id" help:"Comma-separated channel IDs (use with API key)"` + Mine bool `name:"mine" help:"Use authenticated user (requires -a account)"` + Max int64 `name:"max" aliases:"limit" help:"Max results" default:"25"` + Page string `name:"page" help:"Page token"` +} + +func (c *YouTubeChannelsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + if c.ID == "" && !c.Mine { + return usage("set --id CHANNEL_IDS or --mine (--mine requires -a account)") + } + if c.ID != "" && c.Mine { + return usage("use either --id or --mine, not both") + } + + var svc *youtube.Service + var err error + if c.Mine { + account, accErr := requireAccount(flags) + if accErr != nil { + return accErr + } + svc, err = getYouTubeServiceForAccount(ctx, account) + } else { + svc, err = getYouTubeServiceWithAPIKey(ctx) + } + if err != nil { + return err + } + + call := svc.Channels.List([]string{"snippet", "statistics", "contentDetails"}). + MaxResults(c.Max). + PageToken(c.Page) + if c.ID != "" { + call = call.Id(strings.Split(c.ID, ",")...) + } else { + call = call.Mine(true) + } + resp, err := call.Do() + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "items": resp.Items, + "nextPageToken": resp.NextPageToken, + }) + } + if len(resp.Items) == 0 { + u.Err().Println("No channels") + return nil + } + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ID\tTITLE\tSUBS\tVIDEOS\tVIEWS\tPUBLISHED_AT") + for _, ch := range resp.Items { + title := "" + pubAt := "" + subs := "" + videos := "" + views := "" + if ch.Snippet != nil { + title = ch.Snippet.Title + pubAt = ch.Snippet.PublishedAt + } + if ch.Statistics != nil { + subs = fmt.Sprintf("%d", ch.Statistics.SubscriberCount) + videos = fmt.Sprintf("%d", ch.Statistics.VideoCount) + views = fmt.Sprintf("%d", ch.Statistics.ViewCount) + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", ch.Id, sanitizeTab(title), subs, videos, views, sanitizeTab(pubAt)) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} diff --git a/internal/cmd/youtube_services.go b/internal/cmd/youtube_services.go new file mode 100644 index 00000000..730055d4 --- /dev/null +++ b/internal/cmd/youtube_services.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "context" + + youtube "google.golang.org/api/youtube/v3" + + "github.com/steipete/gogcli/internal/config" + "github.com/steipete/gogcli/internal/googleapi" +) + +var ( + newYouTubeWithAPIKey = googleapi.NewYouTubeWithAPIKey + newYouTubeForAccount = googleapi.NewYouTubeForAccount +) + +func getYouTubeAPIKey() (string, error) { + cfg, err := config.ReadConfig() + if err != nil { + return "", err + } + key := config.GetValue(cfg, config.KeyYoutubeAPIKey) + if key == "" { + return "", usage("YouTube API key required: set config youtube_api_key KEY or GOG_YOUTUBE_API_KEY") + } + return key, nil +} + +func getYouTubeServiceWithAPIKey(ctx context.Context) (*youtube.Service, error) { + key, err := getYouTubeAPIKey() + if err != nil { + return nil, err + } + return newYouTubeWithAPIKey(ctx, key) +} + +func getYouTubeServiceForAccount(ctx context.Context, account string) (*youtube.Service, error) { + return newYouTubeForAccount(ctx, account) +} diff --git a/internal/config/config.go b/internal/config/config.go index 50ab6abe..ba5dbeba 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,6 +12,7 @@ import ( type File struct { KeyringBackend string `json:"keyring_backend,omitempty"` DefaultTimezone string `json:"default_timezone,omitempty"` + YoutubeAPIKey string `json:"youtube_api_key,omitempty"` AccountAliases map[string]string `json:"account_aliases,omitempty"` AccountClients map[string]string `json:"account_clients,omitempty"` ClientDomains map[string]string `json:"client_domains,omitempty"` diff --git a/internal/config/keys.go b/internal/config/keys.go index beb9115e..d30a3159 100644 --- a/internal/config/keys.go +++ b/internal/config/keys.go @@ -3,6 +3,7 @@ package config import ( "errors" "fmt" + "os" "strings" "time" ) @@ -12,6 +13,7 @@ type Key string const ( KeyTimezone Key = "timezone" KeyKeyringBackend Key = "keyring_backend" + KeyYoutubeAPIKey Key = "youtube_api_key" ) type KeySpec struct { @@ -25,6 +27,7 @@ type KeySpec struct { var keyOrder = []Key{ KeyTimezone, KeyKeyringBackend, + KeyYoutubeAPIKey, } var keySpecs = map[Key]KeySpec{ @@ -63,6 +66,25 @@ var keySpecs = map[Key]KeySpec{ return "(not set, using auto)" }, }, + KeyYoutubeAPIKey: { + Key: KeyYoutubeAPIKey, + Get: func(cfg File) string { + if v := os.Getenv("GOG_YOUTUBE_API_KEY"); v != "" { + return v + } + return cfg.YoutubeAPIKey + }, + Set: func(cfg *File, value string) error { + cfg.YoutubeAPIKey = value + return nil + }, + Unset: func(cfg *File) { + cfg.YoutubeAPIKey = "" + }, + EmptyHint: func() string { + return "(not set; set for YouTube Data API: config set youtube_api_key KEY or GOG_YOUTUBE_API_KEY)" + }, + }, } var ( diff --git a/internal/googleapi/youtube.go b/internal/googleapi/youtube.go new file mode 100644 index 00000000..3ee4060a --- /dev/null +++ b/internal/googleapi/youtube.go @@ -0,0 +1,56 @@ +package googleapi + +import ( + "context" + "errors" + "fmt" + "net/http" + + "google.golang.org/api/option" + youtube "google.golang.org/api/youtube/v3" + + "github.com/steipete/gogcli/internal/googleauth" +) + +var errYouTubeAPIKeyRequired = errors.New("youtube: API key required (config set youtube_api_key KEY or GOG_YOUTUBE_API_KEY)") + +// NewYouTubeWithAPIKey creates a YouTube Data API v3 service client using an API key. +// Use for public data: list by channelId, videoId, playlistId, etc. +// API key can be set via config (youtube_api_key) or GOG_YOUTUBE_API_KEY. +func NewYouTubeWithAPIKey(ctx context.Context, apiKey string) (*youtube.Service, error) { + if apiKey == "" { + return nil, errYouTubeAPIKeyRequired + } + + transport := NewRetryTransport(newBaseTransport()) + opts := []option.ClientOption{ + option.WithAPIKey(apiKey), + option.WithHTTPClient(&http.Client{ + Transport: transport, + Timeout: defaultHTTPTimeout, + }), + } + + svc, err := youtube.NewService(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("youtube service with API key: %w", err) + } + + return svc, nil +} + +// NewYouTubeForAccount creates a YouTube Data API v3 service client using OAuth for the given account. +// Use for "mine" operations (authenticated user's channel, playlists, activities). +func NewYouTubeForAccount(ctx context.Context, email string) (*youtube.Service, error) { + opts, err := optionsForAccount(ctx, googleauth.ServiceYouTube, email) + if err != nil { + return nil, fmt.Errorf("youtube OAuth options: %w", err) + } + + svc, err := youtube.NewService(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("youtube service for account: %w", err) + } + + return svc, nil +} diff --git a/internal/googleauth/service.go b/internal/googleauth/service.go index f464af91..7655b8f3 100644 --- a/internal/googleauth/service.go +++ b/internal/googleauth/service.go @@ -25,6 +25,7 @@ const ( ServiceAppScript Service = "appscript" ServiceGroups Service = "groups" ServiceKeep Service = "keep" + ServiceYouTube Service = "youtube" ) const ( @@ -74,6 +75,7 @@ var serviceOrder = []Service{ ServiceAppScript, ServiceGroups, ServiceKeep, + ServiceYouTube, } var serviceInfoByService = map[Service]serviceInfo{ @@ -203,6 +205,12 @@ var serviceInfoByService = map[Service]serviceInfo{ apis: []string{"Keep API"}, note: "Workspace only; service account (domain-wide delegation)", }, + ServiceYouTube: { + scopes: []string{"https://www.googleapis.com/auth/youtube.readonly"}, + user: true, + apis: []string{"YouTube Data API v3"}, + note: "Most read operations also work with API key only (config youtube_api_key or GOG_YOUTUBE_API_KEY)", + }, } func ParseService(s string) (Service, error) { @@ -522,6 +530,12 @@ func scopesForServiceWithOptions(service Service, opts ScopeOptions) ([]string, return Scopes(service) case ServiceKeep: return Scopes(service) + case ServiceYouTube: + if opts.Readonly { + return []string{"https://www.googleapis.com/auth/youtube.readonly"}, nil + } + + return []string{"https://www.googleapis.com/auth/youtube.force-ssl"}, nil default: return nil, errUnknownService } diff --git a/internal/googleauth/service_test.go b/internal/googleauth/service_test.go index 445de2d1..c1279aea 100644 --- a/internal/googleauth/service_test.go +++ b/internal/googleauth/service_test.go @@ -65,7 +65,7 @@ func TestExtractCodeAndState_Errors(t *testing.T) { func TestAllServices(t *testing.T) { svcs := AllServices() - if len(svcs) != 15 { + if len(svcs) != 16 { t.Fatalf("unexpected: %v", svcs) } seen := make(map[Service]bool) @@ -74,7 +74,7 @@ func TestAllServices(t *testing.T) { seen[s] = true } - for _, want := range []Service{ServiceGmail, ServiceCalendar, ServiceChat, ServiceClassroom, ServiceDrive, ServiceDocs, ServiceSlides, ServiceContacts, ServiceTasks, ServicePeople, ServiceSheets, ServiceForms, ServiceAppScript, ServiceGroups, ServiceKeep} { + for _, want := range []Service{ServiceGmail, ServiceCalendar, ServiceChat, ServiceClassroom, ServiceDrive, ServiceDocs, ServiceSlides, ServiceContacts, ServiceTasks, ServicePeople, ServiceSheets, ServiceForms, ServiceAppScript, ServiceGroups, ServiceKeep, ServiceYouTube} { if !seen[want] { t.Fatalf("missing %q", want) } @@ -83,7 +83,7 @@ func TestAllServices(t *testing.T) { func TestUserServices(t *testing.T) { svcs := UserServices() - if len(svcs) != 13 { + if len(svcs) != 14 { t.Fatalf("unexpected: %v", svcs) } @@ -96,7 +96,7 @@ func TestUserServices(t *testing.T) { seenDocs = true case ServiceSlides: seenSlides = true - case ServiceForms, ServiceAppScript: + case ServiceForms, ServiceAppScript, ServiceYouTube: // expected user services case ServiceKeep: t.Fatalf("unexpected keep in user services") @@ -113,7 +113,7 @@ func TestUserServices(t *testing.T) { } func TestUserServiceCSV(t *testing.T) { - want := "gmail,calendar,chat,classroom,drive,docs,slides,contacts,tasks,sheets,people,forms,appscript" + want := "gmail,calendar,chat,classroom,drive,docs,slides,contacts,tasks,sheets,people,forms,appscript,youtube" if got := UserServiceCSV(); got != want { t.Fatalf("unexpected user services csv: %q", got) }