Files
yutu/pkg/common/common.go
T

238 lines
6.2 KiB
Go

// Copyright 2026 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package common
import (
"context"
"errors"
"fmt"
"io"
"math"
"github.com/eat-pray-ai/yutu/pkg"
"github.com/eat-pray-ai/yutu/pkg/auth"
"github.com/eat-pray-ai/yutu/pkg/utils"
"github.com/jedib0t/go-pretty/v6/table"
sdkauth "github.com/modelcontextprotocol/go-sdk/auth"
"golang.org/x/oauth2"
"google.golang.org/api/googleapi"
"google.golang.org/api/option"
"google.golang.org/api/youtube/v3"
)
// ServiceProvider resolves an authenticated YouTube service for the given
// context. Override DefaultServiceProvider at startup to inject a custom
// credential resolution strategy.
type ServiceProvider interface {
GetYouTubeService(ctx context.Context) (*youtube.Service, error)
}
const DefaultRedirectURL = "http://localhost:8216"
type defaultProvider struct{}
// DefaultServiceProvider is used by Fields.EnsureService to create a
// YouTube service when one is not already set.
var DefaultServiceProvider ServiceProvider = &defaultProvider{}
type Fields struct {
Ctx context.Context `yaml:"-" json:"-"`
Service *youtube.Service `yaml:"-" json:"-"`
Ids []string `yaml:"ids" json:"ids,omitempty"`
Confirmed bool `yaml:"-" json:"confirmed,omitempty"`
MaxResults int64 `yaml:"max_results" json:"max_results,omitzero"`
Hl string `yaml:"hl" json:"hl,omitempty"`
ChannelId string `yaml:"channel_id" json:"channel_id,omitempty"`
Parts []string `yaml:"parts" json:"parts,omitempty"`
Output string `yaml:"output" json:"output,omitempty"`
OnBehalfOfContentOwner string `yaml:"on_behalf_of_content_owner" json:"on_behalf_of_content_owner,omitempty"`
}
func (d *Fields) GetFields() *Fields {
return d
}
// SetContext implements the cobra-mcp ContextAware interface, enabling
// automatic context injection from MCP tool handlers.
func (d *Fields) SetContext(ctx context.Context) {
d.Ctx = ctx
}
func (d *Fields) EnsureService() error {
if d.Service != nil {
return nil
}
ctx := d.Ctx
if ctx == nil {
ctx = context.Background()
}
svc, err := DefaultServiceProvider.GetYouTubeService(ctx)
if err != nil {
return err
}
d.Service = svc
return nil
}
func (p *defaultProvider) GetYouTubeService(ctx context.Context) (*youtube.Service, error) {
// MCP OAuth path: use access token from auth middleware context.
if tokenInfo := sdkauth.TokenInfoFromContext(ctx); tokenInfo != nil {
rawToken, ok := tokenInfo.Extra["access_token"].(string)
if !ok || rawToken == "" {
return nil, fmt.Errorf("token info present but missing access_token")
}
ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: rawToken})
client := oauth2.NewClient(ctx, ts)
svc, err := youtube.NewService(ctx, option.WithHTTPClient(client))
if err != nil {
return nil, fmt.Errorf("failed to create YouTube service: %w", err)
}
return svc, nil
}
// File-based auth path (CLI / stdio mode).
svc, err := auth.NewY2BService(
auth.WithCredential("", pkg.Root.FS()),
auth.WithCacheToken("", pkg.Root.FS()),
auth.WithRedirectURL(DefaultRedirectURL),
).GetService()
if err != nil {
return nil, fmt.Errorf("failed to create YouTube service: %w", err)
}
return svc, nil
}
type HasFields interface {
GetFields() *Fields
EnsureService() error
}
func WithParts[T HasFields](parts []string) func(T) {
return func(t T) {
t.GetFields().Parts = parts
}
}
func WithOutput[T HasFields](output string) func(T) {
return func(t T) {
t.GetFields().Output = output
}
}
func WithService[T HasFields](svc *youtube.Service) func(T) {
return func(t T) {
t.GetFields().Service = svc
}
}
func WithIds[T HasFields](ids []string) func(T) {
return func(t T) {
t.GetFields().Ids = ids
}
}
func WithMaxResults[T HasFields](maxResults int64) func(T) {
return func(t T) {
if maxResults < 0 {
t.GetFields().MaxResults = 1
} else if maxResults == 0 {
t.GetFields().MaxResults = math.MaxInt64
} else {
t.GetFields().MaxResults = maxResults
}
}
}
func WithHl[T HasFields](hl string) func(T) {
return func(t T) {
t.GetFields().Hl = hl
}
}
func WithChannelId[T HasFields](channelId string) func(T) {
return func(t T) {
t.GetFields().ChannelId = channelId
}
}
func WithOnBehalfOfContentOwner[T HasFields](owner string) func(T) {
return func(t T) {
t.GetFields().OnBehalfOfContentOwner = owner
}
}
// PrintList handles the json/yaml/table output switch for List methods.
// The header and row function are only used for table output.
func PrintList[T any](output string, items []*T, w io.Writer, header table.Row, row func(*T) table.Row) {
switch output {
case "json":
utils.PrintJSON(items, w)
case "yaml":
utils.PrintYAML(items, w)
case "table":
tb := table.NewWriter()
defer tb.Render()
tb.SetOutputMirror(w)
tb.SetStyle(pkg.TableStyle)
tb.AppendHeader(header)
for _, item := range items {
tb.AppendRow(row(item))
}
}
}
// PrintResult handles the json/yaml/silent/default output switch for mutation methods.
func PrintResult(output string, data any, w io.Writer, format string, args ...any) {
switch output {
case "json":
utils.PrintJSON(data, w)
case "yaml":
utils.PrintYAML(data, w)
case "silent":
default:
_, _ = fmt.Fprintf(w, format, args...)
}
}
// PagedLister is satisfied by all YouTube API *XxxListCall types.
type PagedLister[C any, R any] interface {
MaxResults(int64) C
PageToken(string) C
Do(opts ...googleapi.CallOption) (*R, error)
}
// Paginate fetches all pages of results. It handles MaxResults, PageToken,
// Do(), and error wrapping automatically. The extract function pulls items
// and the next page token from the response.
func Paginate[C PagedLister[C, R], R any, T any](
f *Fields, call C,
extract func(*R) ([]*T, string),
errWrap error,
) ([]*T, error) {
var items []*T
remaining := f.MaxResults
pageToken := ""
for remaining > 0 {
call = call.MaxResults(min(remaining, pkg.PerPage))
if pageToken != "" {
call = call.PageToken(pageToken)
}
res, err := call.Do()
if err != nil {
return items, errors.Join(errWrap, err)
}
got, nextToken := extract(res)
remaining -= pkg.PerPage
items = append(items, got...)
pageToken = nextToken
if pageToken == "" || len(got) == 0 {
break
}
}
return items, nil
}