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 @@

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