238 lines
6.2 KiB
Go
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
|
|
}
|