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) }