// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package activity
import (
"errors"
"io"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetActivity = errors.New("failed to get activity")
)
type Activity struct {
*common.Fields
Home *bool `yaml:"home" json:"home,omitempty"`
Mine *bool `yaml:"mine" json:"mine,omitempty"`
PublishedAfter string `yaml:"published_after" json:"published_after,omitempty"`
PublishedBefore string `yaml:"published_before" json:"published_before,omitempty"`
RegionCode string `yaml:"region_code" json:"region_code,omitempty"`
}
type IActivity[T any] interface {
List(io.Writer) error
Get() ([]*T, error)
}
type Option func(*Activity)
func NewActivity(opts ...Option) IActivity[youtube.Activity] {
a := &Activity{Fields: &common.Fields{}}
for _, opt := range opts {
opt(a)
}
return a
}
func (a *Activity) Get() ([]*youtube.Activity, error) {
if err := a.EnsureService(); err != nil {
return nil, err
}
call := a.Service.Activities.List(a.Parts)
if a.ChannelId != "" {
call = call.ChannelId(a.ChannelId)
}
if a.Home != nil {
call = call.Home(*a.Home)
}
if a.Mine != nil {
call = call.Mine(*a.Mine)
}
if a.PublishedAfter != "" {
call = call.PublishedAfter(a.PublishedAfter)
}
if a.PublishedBefore != "" {
call = call.PublishedBefore(a.PublishedBefore)
}
if a.RegionCode != "" {
call = call.RegionCode(a.RegionCode)
}
return common.Paginate(
a.Fields, call,
func(r *youtube.ActivityListResponse) ([]*youtube.Activity, string) {
return r.Items, r.NextPageToken
}, errGetActivity,
)
}
func (a *Activity) List(writer io.Writer) error {
activities, err := a.Get()
if err != nil && activities == nil {
return err
}
common.PrintList(
a.Output, activities, writer, table.Row{"ID", "Title", "Type", "Time"},
func(a *youtube.Activity) table.Row {
return table.Row{a.Id, a.Snippet.Title, a.Snippet.Type, a.Snippet.PublishedAt}
},
)
return err
}
func WithHome(home *bool) Option {
return func(a *Activity) {
if home != nil {
a.Home = home
}
}
}
func WithMine(mine *bool) Option {
return func(a *Activity) {
if mine != nil {
a.Mine = mine
}
}
}
func WithPublishedAfter(publishedAfter string) Option {
return func(a *Activity) {
a.PublishedAfter = publishedAfter
}
}
func WithPublishedBefore(publishedBefore string) Option {
return func(a *Activity) {
a.PublishedBefore = publishedBefore
}
}
func WithRegionCode(regionCode string) Option {
return func(a *Activity) {
a.RegionCode = regionCode
}
}
var (
WithChannelId = common.WithChannelId[*Activity]
WithMaxResults = common.WithMaxResults[*Activity]
WithParts = common.WithParts[*Activity]
WithOutput = common.WithOutput[*Activity]
WithService = common.WithService[*Activity]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package auth
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/eat-pray-ai/yutu/pkg"
"github.com/eat-pray-ai/yutu/pkg/utils"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/option"
"google.golang.org/api/youtube/v3"
)
const (
createSvcFailed = "failed to create YouTube service"
parseUrlFailed = "failed to parse redirect URL"
stateMatchFailed = "state doesn't match, possible CSRF attack"
readPromptFailed = "failed to read prompt"
exchangeFailed = "failed to exchange token"
listenFailed = "failed to start web server"
cacheTokenFailed = "failed to cache token"
parseTokenFailed = "failed to parse token"
refreshTokenFailed = "failed to refresh token, please re-authenticate in cli"
parseSecretFailed = "failed to parse client secret"
browserOpenedHint = "Your browser has been opened to an authorization URL. yutu will resume once authorization has been provided.\n%s\n"
openBrowserHint = "It seems that your browser is not open. Go to the following link in your browser:\n%s\n"
receivedCodeHint = "Authorization code received: %s\nYou can now safely close the browser window.\n"
manualInputHint = `
After completing the authorization flow, enter the authorization code on command line.
If you end up in an error page after completing the authorization flow,
and the url in the address bar is in the form of
'%s/?state=DONOT-COPY&code=COPY-THIS&scope=DONOT-COPY'
ONLY 'COPY-THIS' part is the code you need to enter on command line.
`
)
var (
scope = []string{
youtube.YoutubeScope,
youtube.YoutubeForceSslScope,
youtube.YoutubeChannelMembershipsCreatorScope,
}
)
func (s *svc) GetService() (*youtube.Service, error) {
if s.initErr != nil {
return nil, s.initErr
}
client, err := s.refreshClient()
if err != nil {
return nil, err
}
service, err := youtube.NewService(s.ctx, option.WithHTTPClient(client))
if err != nil {
return nil, fmt.Errorf("%s: %w", createSvcFailed, err)
}
s.service = service
return s.service, nil
}
func (s *svc) refreshClient() (client *http.Client, err error) {
config, err := s.getConfig()
if err != nil {
return nil, err
}
authedToken := &oauth2.Token{}
err = json.Unmarshal([]byte(s.CacheToken), authedToken)
if err != nil {
client, authedToken, err = s.newClient(config)
if err != nil {
return nil, err
}
if s.tokenFile != "" {
if err := s.saveToken(authedToken); err != nil {
return nil, err
}
}
return client, nil
}
if !authedToken.Valid() {
tokenSource := config.TokenSource(s.ctx, authedToken)
authedToken, err = tokenSource.Token()
if err != nil && s.tokenFile != "" {
client, authedToken, err = s.newClient(config)
if err != nil {
return nil, err
}
if err := s.saveToken(authedToken); err != nil {
return nil, err
}
return client, nil
} else if err != nil {
return nil, fmt.Errorf("%s: %w", refreshTokenFailed, err)
}
if authedToken != nil && s.tokenFile != "" {
if err := s.saveToken(authedToken); err != nil {
return nil, err
}
}
return config.Client(s.ctx, authedToken), nil
}
return config.Client(s.ctx, authedToken), nil
}
func (s *svc) newClient(config *oauth2.Config) (
client *http.Client, token *oauth2.Token, err error,
) {
verifier := oauth2.GenerateVerifier()
authURL := config.AuthCodeURL(
s.state,
oauth2.ApprovalForce,
oauth2.AccessTypeOffline,
oauth2.S256ChallengeOption(verifier),
)
token, err = s.getTokenFromWeb(config, authURL, verifier)
if err != nil {
return nil, nil, err
}
client = config.Client(s.ctx, token)
return client, token, nil
}
func (s *svc) getConfig() (*oauth2.Config, error) {
config, err := google.ConfigFromJSON([]byte(s.Credential), scope...)
if err != nil {
return nil, fmt.Errorf("%s: %w", parseSecretFailed, err)
}
return config, nil
}
func (s *svc) startWebServer(redirectURL string) (chan string, error) {
u, err := url.Parse(redirectURL)
if err != nil {
return nil, fmt.Errorf("%s: %w", parseUrlFailed, err)
}
listener, err := net.Listen("tcp", u.Host)
if err != nil {
return nil, fmt.Errorf("%s: %w", listenFailed, err)
}
codeCh := make(chan string)
go func() {
_ = http.Serve(
listener, http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
return
}
state := r.FormValue("state")
if state != s.state {
slog.Error(
stateMatchFailed, "actual", state, "expected", s.state,
)
return
}
code := r.FormValue("code")
codeCh <- code
_ = listener.Close()
w.Header().Set("Content-Type", "text/plain")
_, _ = fmt.Fprintf(w, receivedCodeHint, code)
},
),
)
}()
return codeCh, nil
}
func (s *svc) getCodeFromPrompt(authURL string, redirectURL string) (code string, err error) {
_, _ = fmt.Fprintf(s.out, openBrowserHint, authURL)
_, _ = fmt.Fprintf(s.out, manualInputHint, redirectURL)
_, err = fmt.Fscan(s.in, &code)
if err != nil {
return "", fmt.Errorf("%s: %w", readPromptFailed, err)
}
if strings.HasPrefix(code, "4%2F") {
code = strings.Replace(code, "4%2F", "4/", 1)
}
return code, nil
}
func (s *svc) getTokenFromWeb(
config *oauth2.Config, authURL string, verifier string,
) (*oauth2.Token, error) {
codeCh, err := s.startWebServer(config.RedirectURL)
if err != nil {
return nil, err
}
var code string
if err := utils.OpenURL(authURL); err == nil {
_, _ = fmt.Fprintf(s.out, browserOpenedHint, authURL)
code = <-codeCh
}
if code == "" {
code, err = s.getCodeFromPrompt(authURL, config.RedirectURL)
if err != nil {
return nil, err
}
}
slog.Debug("Authorization code generated", "code", code)
token, err := config.Exchange(
context.TODO(), code, oauth2.VerifierOption(verifier),
)
if err != nil {
return nil, fmt.Errorf("%s: %w", exchangeFailed, err)
}
return token, nil
}
func (s *svc) saveToken(token *oauth2.Token) error {
dir := filepath.Dir(s.tokenFile)
if err := pkg.Root.MkdirAll(dir, 0755); err != nil {
slog.Error(cacheTokenFailed, "dir", dir, "error", err)
return fmt.Errorf("%s: %w", cacheTokenFailed, err)
}
f, err := pkg.Root.OpenFile(
s.tokenFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600,
)
if err != nil {
slog.Error(cacheTokenFailed, "file", s.tokenFile, "error", err)
return fmt.Errorf("%s: %w", cacheTokenFailed, err)
}
defer func() {
_ = f.Close()
}()
err = json.NewEncoder(f).Encode(token)
if err != nil {
return fmt.Errorf("%s: %w", cacheTokenFailed, err)
}
slog.Debug("Token cached to file", "file", s.tokenFile)
return nil
}
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package auth
import (
"context"
"encoding/base64"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/eat-pray-ai/yutu/pkg"
"github.com/eat-pray-ai/yutu/pkg/utils"
"google.golang.org/api/youtube/v3"
)
const (
readTokenFailed = "failed to read token"
readSecretFailed = "failed to read client secret"
authHint = "Please configure client secret as described in https://github.com/eat-pray-ai/yutu#prerequisites"
)
type svc struct {
Credential string `yaml:"credential" json:"credential"`
CacheToken string `yaml:"cache_token" json:"cache_token"`
credFile string
tokenFile string
initErr error
in io.Reader
out io.Writer
service *youtube.Service
ctx context.Context
state string
}
type Svc interface {
GetService() (*youtube.Service, error)
}
type Option func(*svc)
func NewY2BService(opts ...Option) Svc {
s := &svc{}
s.ctx = context.Background()
s.credFile = "client_secret.json"
s.state = utils.RandomStage()
s.in = os.Stdin
s.out = os.Stdout
for _, opt := range opts {
opt(s)
}
return s
}
func WithIO(in io.Reader, out io.Writer) Option {
return func(s *svc) {
s.in = in
s.out = out
}
}
func WithCredential(cred string, fsys fs.FS) Option {
return func(s *svc) {
// cred > YUTU_CREDENTIAL
envCred, ok := os.LookupEnv("YUTU_CREDENTIAL")
if cred == "" && ok {
cred = envCred
} else if cred == "" {
cred = s.credFile
}
// 1. cred is a file path
// 2. cred is a base64 encoded string
// 3. cred is a JSON string
absCred, _ := filepath.Abs(cred)
relCred, _ := filepath.Rel(*pkg.RootDir, absCred)
relCred = strings.ReplaceAll(relCred, `\`, `/`)
if _, err := fs.Stat(fsys, relCred); err == nil {
s.credFile = absCred
credBytes, err := fs.ReadFile(fsys, relCred)
if err != nil {
s.initErr = fmt.Errorf("%s: %w", readSecretFailed, err)
slog.Error(
readSecretFailed, "hint", authHint, "path", absCred, "error", err,
)
}
s.Credential = string(credBytes)
return
}
if credB64, err := base64.StdEncoding.DecodeString(cred); err == nil {
s.Credential = string(credB64)
} else if utils.IsJson(cred) {
s.Credential = cred
} else {
s.initErr = fmt.Errorf("%s: %w", parseSecretFailed, err)
slog.Error(parseSecretFailed, "hint", authHint, "error", err)
}
}
}
func WithCacheToken(token string, fsys fs.FS) Option {
return func(s *svc) {
// token > YUTU_CACHE_TOKEN
envToken, ok := os.LookupEnv("YUTU_CACHE_TOKEN")
if token == "" && ok {
token = envToken
} else if token == "" {
token = "youtube.token.json"
}
// 1. token is a file path
// 2. token is a base64 encoded string
// 3. token is a JSON string
absToken, _ := filepath.Abs(token)
relToken, _ := filepath.Rel(*pkg.RootDir, absToken)
relToken = strings.ReplaceAll(relToken, `\`, `/`)
if _, err := fs.Stat(fsys, relToken); err == nil {
tokenBytes, err := fs.ReadFile(fsys, relToken)
if err != nil {
slog.Warn(readTokenFailed, "path", absToken, "error", err)
}
s.tokenFile = relToken
s.CacheToken = string(tokenBytes)
return
} else if os.IsNotExist(err) && strings.HasSuffix(token, ".json") {
s.tokenFile = relToken
return
}
if tokenB64, err := base64.StdEncoding.DecodeString(token); err == nil {
s.CacheToken = string(tokenB64)
} else if utils.IsJson(token) {
s.CacheToken = token
} else {
slog.Warn(parseTokenFailed, "token", token, "error", err)
}
}
}
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package caption
import (
"errors"
"fmt"
"io"
"os"
"github.com/eat-pray-ai/yutu/pkg"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetCaption = errors.New("failed to get caption")
errUpdateCaption = errors.New("failed to update caption")
errDeleteCaption = errors.New("failed to delete caption")
errInsertCaption = errors.New("failed to insert caption")
errDownloadCaption = errors.New("failed to download caption")
)
type Caption struct {
*common.Fields
File string `yaml:"file" json:"file,omitempty"`
AudioTrackType string `yaml:"audio_track_type" json:"audio_track_type,omitempty"`
IsAutoSynced *bool `yaml:"is_auto_synced" json:"is_auto_synced,omitempty"`
IsCC *bool `yaml:"is_cc" json:"is_cc,omitempty"`
IsDraft *bool `yaml:"is_draft" json:"is_draft,omitempty"`
IsEasyReader *bool `yaml:"is_easy_reader" json:"is_easy_reader,omitempty"`
IsLarge *bool `yaml:"is_large" json:"is_large,omitempty"`
Language string `yaml:"language" json:"language,omitempty"`
Name string `yaml:"name" json:"name,omitempty"`
TrackKind string `yaml:"track_kind" json:"track_kind,omitempty"`
OnBehalfOf string `yaml:"on_behalf_of" json:"on_behalf_of,omitempty"`
VideoId string `yaml:"video_id" json:"video_id,omitempty"`
Tfmt string `yaml:"tfmt" json:"tfmt,omitempty"`
Tlang string `yaml:"tlang" json:"tlang,omitempty"`
}
type ICaption[T youtube.Caption] interface {
Get() ([]*T, error)
List(io.Writer) error
Insert(io.Writer) error
Update(io.Writer) error
Delete(io.Writer) error
Download(io.Writer) error
}
type Option func(*Caption)
func NewCaption(opts ...Option) ICaption[youtube.Caption] {
c := &Caption{Fields: &common.Fields{}}
for _, opt := range opts {
opt(c)
}
return c
}
func (c *Caption) GetFields() *common.Fields {
return c.Fields
}
func (c *Caption) Get() ([]*youtube.Caption, error) {
if err := c.EnsureService(); err != nil {
return nil, err
}
call := c.Service.Captions.List(c.Parts, c.VideoId)
if len(c.Ids) > 0 {
call = call.Id(c.Ids...)
}
if c.OnBehalfOf != "" {
call = call.OnBehalfOf(c.OnBehalfOf)
}
if c.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(c.OnBehalfOfContentOwner)
}
res, err := call.Do()
if err != nil {
return nil, errors.Join(errGetCaption, err)
}
return res.Items, nil
}
func (c *Caption) List(writer io.Writer) error {
captions, err := c.Get()
if err != nil {
return err
}
common.PrintList(
c.Output, captions, writer, table.Row{"ID", "Video ID", "Name", "Language"},
func(cap *youtube.Caption) table.Row {
return table.Row{cap.Id, cap.Snippet.VideoId, cap.Snippet.Name, cap.Snippet.Language}
},
)
return nil
}
func (c *Caption) Insert(writer io.Writer) error {
if err := c.EnsureService(); err != nil {
return err
}
file, err := pkg.Root.Open(c.File)
if err != nil {
return errors.Join(errInsertCaption, err)
}
defer func(file *os.File) {
_ = file.Close()
}(file)
caption := &youtube.Caption{
Snippet: &youtube.CaptionSnippet{
AudioTrackType: c.AudioTrackType,
IsAutoSynced: c.IsAutoSynced != nil && *c.IsAutoSynced,
IsCC: c.IsCC != nil && *c.IsCC,
IsDraft: c.IsDraft != nil && *c.IsDraft,
IsEasyReader: c.IsEasyReader != nil && *c.IsEasyReader,
IsLarge: c.IsLarge != nil && *c.IsLarge,
Language: c.Language,
Name: c.Name,
TrackKind: c.TrackKind,
VideoId: c.VideoId,
},
}
call := c.Service.Captions.Insert([]string{"snippet"}, caption).Media(file)
if c.OnBehalfOf != "" {
call = call.OnBehalfOf(c.OnBehalfOf)
}
if c.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(c.OnBehalfOfContentOwner)
}
res, err := call.Do()
if err != nil {
return errors.Join(errInsertCaption, err)
}
common.PrintResult(c.Output, res, writer, "Caption inserted: %s\n", res.Id)
return nil
}
func (c *Caption) Update(writer io.Writer) error {
c.Parts = []string{"snippet"}
captions, err := c.Get()
if err != nil {
return errors.Join(errUpdateCaption, err)
}
if len(captions) == 0 {
return errGetCaption
}
caption := captions[0]
if c.AudioTrackType != "" {
caption.Snippet.AudioTrackType = c.AudioTrackType
}
if c.IsAutoSynced != nil {
caption.Snippet.IsAutoSynced = *c.IsAutoSynced
}
if c.IsCC != nil {
caption.Snippet.IsCC = *c.IsCC
}
if c.IsDraft != nil {
caption.Snippet.IsDraft = *c.IsDraft
}
if c.IsEasyReader != nil {
caption.Snippet.IsEasyReader = *c.IsEasyReader
}
if c.IsLarge != nil {
caption.Snippet.IsLarge = *c.IsLarge
}
if c.Language != "" {
caption.Snippet.Language = c.Language
}
if c.Name != "" {
caption.Snippet.Name = c.Name
}
if c.TrackKind != "" {
caption.Snippet.TrackKind = c.TrackKind
}
if c.VideoId != "" {
caption.Snippet.VideoId = c.VideoId
}
call := c.Service.Captions.Update([]string{"snippet"}, caption)
if c.File != "" {
file, err := pkg.Root.Open(c.File)
if err != nil {
return errors.Join(errUpdateCaption, err)
}
defer func(file *os.File) {
_ = file.Close()
}(file)
call = call.Media(file)
}
if c.OnBehalfOf != "" {
call = call.OnBehalfOf(c.OnBehalfOf)
}
if c.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(c.OnBehalfOfContentOwner)
}
res, err := call.Do()
if err != nil {
return errors.Join(errUpdateCaption, err)
}
common.PrintResult(c.Output, res, writer, "Caption updated: %s\n", res.Id)
return nil
}
func (c *Caption) Delete(writer io.Writer) error {
if err := c.EnsureService(); err != nil {
return err
}
for _, id := range c.Ids {
call := c.Service.Captions.Delete(id)
if c.OnBehalfOf != "" {
call = call.OnBehalfOf(c.OnBehalfOf)
}
if c.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(c.OnBehalfOfContentOwner)
}
err := call.Do()
if err != nil {
return errors.Join(errDeleteCaption, err)
}
_, _ = fmt.Fprintf(writer, "Caption %s deleted\n", id)
}
return nil
}
func (c *Caption) Download(writer io.Writer) error {
if err := c.EnsureService(); err != nil {
return err
}
call := c.Service.Captions.Download(c.Ids[0])
if c.Tfmt != "" {
call = call.Tfmt(c.Tfmt)
}
if c.Tlang != "" {
call = call.Tlang(c.Tlang)
}
if c.OnBehalfOf != "" {
call = call.OnBehalfOf(c.OnBehalfOf)
}
if c.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(c.OnBehalfOfContentOwner)
}
res, err := call.Download()
if err != nil {
return errors.Join(errDownloadCaption, err)
}
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(res.Body)
body, err := io.ReadAll(res.Body)
if err != nil {
return errors.Join(errDownloadCaption, err)
}
file, err := os.Create(c.File)
if err != nil {
return errors.Join(errDownloadCaption, err)
}
defer func(file *os.File) {
_ = file.Close()
}(file)
_, err = file.Write(body)
if err != nil {
return errors.Join(errDownloadCaption, err)
}
_, _ = fmt.Fprintf(writer, "Caption %s downloaded to %s\n", c.Ids[0], c.File)
return nil
}
func WithFile(file string) Option {
return func(c *Caption) {
c.File = file
}
}
func WithAudioTrackType(audioTrackType string) Option {
return func(c *Caption) {
c.AudioTrackType = audioTrackType
}
}
func WithIsAutoSynced(isAutoSynced *bool) Option {
return func(c *Caption) {
if isAutoSynced != nil {
c.IsAutoSynced = isAutoSynced
}
}
}
func WithIsCC(isCC *bool) Option {
return func(c *Caption) {
if isCC != nil {
c.IsCC = isCC
}
}
}
func WithIsDraft(isDraft *bool) Option {
return func(c *Caption) {
if isDraft != nil {
c.IsDraft = isDraft
}
}
}
func WithIsEasyReader(isEasyReader *bool) Option {
return func(c *Caption) {
if isEasyReader != nil {
c.IsEasyReader = isEasyReader
}
}
}
func WithIsLarge(isLarge *bool) Option {
return func(c *Caption) {
if isLarge != nil {
c.IsLarge = isLarge
}
}
}
func WithLanguage(language string) Option {
return func(c *Caption) {
c.Language = language
}
}
func WithName(name string) Option {
return func(c *Caption) {
c.Name = name
}
}
func WithTrackKind(trackKind string) Option {
return func(c *Caption) {
c.TrackKind = trackKind
}
}
func WithOnBehalfOf(onBehalfOf string) Option {
return func(c *Caption) {
c.OnBehalfOf = onBehalfOf
}
}
func WithVideoId(videoId string) Option {
return func(c *Caption) {
c.VideoId = videoId
}
}
func WithTfmt(tfmt string) Option {
return func(c *Caption) {
c.Tfmt = tfmt
}
}
func WithTlang(tlang string) Option {
return func(c *Caption) {
c.Tlang = tlang
}
}
var (
WithIds = common.WithIds[*Caption]
WithParts = common.WithParts[*Caption]
WithOutput = common.WithOutput[*Caption]
WithService = common.WithService[*Caption]
WithOnBehalfOfContentOwner = common.WithOnBehalfOfContentOwner[*Caption]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package channel
import (
"errors"
"io"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetChannel = errors.New("failed to get channel")
errUpdateChannel = errors.New("failed to update channel")
)
type Channel struct {
*common.Fields
CategoryId string `yaml:"category_id" json:"category_id,omitempty"`
ForHandle string `yaml:"for_handle" json:"for_handle,omitempty"`
ForUsername string `yaml:"for_username" json:"for_username,omitempty"`
ManagedByMe *bool `yaml:"managed_by_me" json:"managed_by_me,omitempty"`
Mine *bool `yaml:"mine" json:"mine,omitempty"`
MySubscribers *bool `yaml:"my_subscribers" json:"my_subscribers,omitempty"`
Country string `yaml:"country" json:"country,omitempty"`
CustomUrl string `yaml:"custom_url" json:"custom_url,omitempty"`
DefaultLanguage string `yaml:"default_language" json:"default_language,omitempty"`
Description string `yaml:"description" json:"description,omitempty"`
Title string `yaml:"title" json:"title,omitempty"`
}
type IChannel[T youtube.Channel] interface {
List(io.Writer) error
Update(io.Writer) error
Get() ([]*T, error)
}
type Option func(*Channel)
func NewChannel(opts ...Option) IChannel[youtube.Channel] {
c := &Channel{Fields: &common.Fields{}}
for _, opt := range opts {
opt(c)
}
return c
}
func (c *Channel) Get() ([]*youtube.Channel, error) {
if err := c.EnsureService(); err != nil {
return nil, err
}
call := c.Service.Channels.List(c.Parts)
if c.CategoryId != "" {
call = call.CategoryId(c.CategoryId)
}
if c.ForHandle != "" {
call = call.ForHandle(c.ForHandle)
}
if c.ForUsername != "" {
call = call.ForUsername(c.ForUsername)
}
if c.Hl != "" {
call = call.Hl(c.Hl)
}
if len(c.Ids) > 0 {
call = call.Id(c.Ids...)
}
if c.ManagedByMe != nil {
call = call.ManagedByMe(*c.ManagedByMe)
}
if c.Mine != nil {
call = call.Mine(*c.Mine)
}
if c.MySubscribers != nil {
call = call.MySubscribers(*c.MySubscribers)
}
if c.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(c.OnBehalfOfContentOwner)
}
return common.Paginate(
c.Fields, call,
func(r *youtube.ChannelListResponse) ([]*youtube.Channel, string) {
return r.Items, r.NextPageToken
}, errGetChannel,
)
}
func (c *Channel) List(writer io.Writer) error {
channels, err := c.Get()
if err != nil && channels == nil {
return err
}
common.PrintList(
c.Output, channels, writer, table.Row{"ID", "Title", "Country"},
func(ch *youtube.Channel) table.Row {
title := ""
country := ""
if ch.Snippet != nil {
title = ch.Snippet.Title
country = ch.Snippet.Country
}
return table.Row{ch.Id, title, country}
},
)
return err
}
func (c *Channel) Update(writer io.Writer) error {
c.Parts = []string{"snippet"}
channels, err := c.Get()
if err != nil {
return errors.Join(errUpdateChannel, err)
}
if len(channels) == 0 {
return errGetChannel
}
cha := channels[0]
if c.Country != "" {
cha.Snippet.Country = c.Country
}
if c.CustomUrl != "" {
cha.Snippet.CustomUrl = c.CustomUrl
}
if c.DefaultLanguage != "" {
cha.Snippet.DefaultLanguage = c.DefaultLanguage
}
if c.Description != "" {
cha.Snippet.Description = c.Description
}
if c.Title != "" {
cha.Snippet.Title = c.Title
}
call := c.Service.Channels.Update(c.Parts, cha)
res, err := call.Do()
if err != nil {
return errors.Join(errUpdateChannel, err)
}
common.PrintResult(c.Output, res, writer, "Channel updated: %s\n", res.Id)
return nil
}
func WithCategoryId(categoryId string) Option {
return func(c *Channel) {
c.CategoryId = categoryId
}
}
func WithForHandle(handle string) Option {
return func(c *Channel) {
c.ForHandle = handle
}
}
func WithForUsername(username string) Option {
return func(c *Channel) {
c.ForUsername = username
}
}
func WithChannelManagedByMe(managedByMe *bool) Option {
return func(c *Channel) {
if managedByMe != nil {
c.ManagedByMe = managedByMe
}
}
}
func WithMine(mine *bool) Option {
return func(c *Channel) {
if mine != nil {
c.Mine = mine
}
}
}
func WithMySubscribers(mySubscribers *bool) Option {
return func(c *Channel) {
if mySubscribers != nil {
c.MySubscribers = mySubscribers
}
}
}
func WithCountry(country string) Option {
return func(c *Channel) {
c.Country = country
}
}
func WithCustomUrl(url string) Option {
return func(c *Channel) {
c.CustomUrl = url
}
}
func WithDefaultLanguage(language string) Option {
return func(c *Channel) {
c.DefaultLanguage = language
}
}
func WithDescription(desc string) Option {
return func(c *Channel) {
c.Description = desc
}
}
func WithTitle(title string) Option {
return func(c *Channel) {
c.Title = title
}
}
var (
WithParts = common.WithParts[*Channel]
WithOutput = common.WithOutput[*Channel]
WithService = common.WithService[*Channel]
WithIds = common.WithIds[*Channel]
WithMaxResults = common.WithMaxResults[*Channel]
WithHl = common.WithHl[*Channel]
WithOnBehalfOfContentOwner = common.WithOnBehalfOfContentOwner[*Channel]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package channelBanner
import (
"errors"
"io"
"os"
"github.com/eat-pray-ai/yutu/pkg"
"github.com/eat-pray-ai/yutu/pkg/common"
"google.golang.org/api/youtube/v3"
)
var (
errInsertChannelBanner = errors.New("failed to insert channelBanner")
)
type ChannelBanner struct {
*common.Fields
File string `yaml:"file" json:"file,omitempty"`
OnBehalfOfContentOwnerChannel string `yaml:"on_behalf_of_content_owner_channel" json:"on_behalf_of_content_owner_channel,omitempty"`
}
type IChannelBanner interface {
Insert(io.Writer) error
}
type Option func(banner *ChannelBanner)
func NewChannelBanner(opts ...Option) IChannelBanner {
cb := &ChannelBanner{Fields: &common.Fields{}}
for _, opt := range opts {
opt(cb)
}
return cb
}
func (cb *ChannelBanner) Insert(writer io.Writer) error {
if err := cb.EnsureService(); err != nil {
return err
}
file, err := pkg.Root.Open(cb.File)
if err != nil {
return errors.Join(errInsertChannelBanner, err)
}
defer func(file *os.File) {
_ = file.Close()
}(file)
cbr := &youtube.ChannelBannerResource{}
call := cb.Service.ChannelBanners.Insert(cbr).ChannelId(cb.ChannelId).Media(file)
if cb.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(cb.OnBehalfOfContentOwner)
}
if cb.OnBehalfOfContentOwnerChannel != "" {
call = call.OnBehalfOfContentOwnerChannel(cb.OnBehalfOfContentOwnerChannel)
}
res, err := call.Do()
if err != nil {
return errors.Join(errInsertChannelBanner, err)
}
common.PrintResult(
cb.Output, res, writer, "ChannelBanner inserted: %s\n", res.Url,
)
return nil
}
func WithFile(file string) Option {
return func(cb *ChannelBanner) {
cb.File = file
}
}
func WithOnBehalfOfContentOwnerChannel(onBehalfOfContentOwnerChannel string) Option {
return func(cb *ChannelBanner) {
cb.OnBehalfOfContentOwnerChannel = onBehalfOfContentOwnerChannel
}
}
var (
WithChannelId = common.WithChannelId[*ChannelBanner]
WithOutput = common.WithOutput[*ChannelBanner]
WithService = common.WithService[*ChannelBanner]
WithOnBehalfOfContentOwner = common.WithOnBehalfOfContentOwner[*ChannelBanner]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package channelSection
import (
"errors"
"fmt"
"io"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetChannelSection = errors.New("failed to get channel section")
errDeleteChannelSection = errors.New("failed to delete channel section")
)
type ChannelSection struct {
*common.Fields
Mine *bool `yaml:"mine" json:"mine,omitempty"`
}
type IChannelSection[T any] interface {
Get() ([]*T, error)
List(io.Writer) error
Delete(io.Writer) error
// Update()
// Insert()
}
type Option func(*ChannelSection)
func NewChannelSection(opts ...Option) IChannelSection[youtube.ChannelSection] {
cs := &ChannelSection{Fields: &common.Fields{}}
for _, opt := range opts {
opt(cs)
}
return cs
}
func (cs *ChannelSection) GetFields() *common.Fields {
return cs.Fields
}
func (cs *ChannelSection) Get() (
[]*youtube.ChannelSection, error,
) {
if err := cs.EnsureService(); err != nil {
return nil, err
}
call := cs.Service.ChannelSections.List(cs.Parts)
if len(cs.Ids) > 0 {
call = call.Id(cs.Ids...)
}
if cs.ChannelId != "" {
call = call.ChannelId(cs.ChannelId)
}
if cs.Hl != "" {
call = call.Hl(cs.Hl)
}
if cs.Mine != nil {
call = call.Mine(*cs.Mine)
}
if cs.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(cs.OnBehalfOfContentOwner)
}
res, err := call.Do()
if err != nil {
return nil, errors.Join(errGetChannelSection, err)
}
return res.Items, nil
}
func (cs *ChannelSection) List(writer io.Writer) error {
channelSections, err := cs.Get()
if err != nil {
return err
}
common.PrintList(
cs.Output, channelSections, writer, table.Row{"ID", "Channel ID", "Title"},
func(s *youtube.ChannelSection) table.Row {
return table.Row{s.Id, s.Snippet.ChannelId, s.Snippet.Title}
},
)
return nil
}
func (cs *ChannelSection) Delete(writer io.Writer) error {
if err := cs.EnsureService(); err != nil {
return err
}
for _, id := range cs.Ids {
call := cs.Service.ChannelSections.Delete(id)
if cs.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(cs.OnBehalfOfContentOwner)
}
err := call.Do()
if err != nil {
return errors.Join(errDeleteChannelSection, err)
}
_, _ = fmt.Fprintf(writer, "Channel section %s deleted\n", id)
}
return nil
}
func WithMine(mine *bool) Option {
return func(cs *ChannelSection) {
if mine != nil {
cs.Mine = mine
}
}
}
var (
WithParts = common.WithParts[*ChannelSection]
WithOutput = common.WithOutput[*ChannelSection]
WithService = common.WithService[*ChannelSection]
WithIds = common.WithIds[*ChannelSection]
WithHl = common.WithHl[*ChannelSection]
WithChannelId = common.WithChannelId[*ChannelSection]
WithOnBehalfOfContentOwner = common.WithOnBehalfOfContentOwner[*ChannelSection]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package comment
import (
"errors"
"fmt"
"io"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetComment = errors.New("failed to get comment")
errMarkAsSpam = errors.New("failed to mark comment as spam")
errDeleteComment = errors.New("failed to delete comment")
errInsertComment = errors.New("failed to insert comment")
errUpdateComment = errors.New("failed to update comment")
errSetModerationStatus = errors.New("failed to set comment moderation status")
)
type Comment struct {
*common.Fields
AuthorChannelId string `yaml:"author_channel_id" json:"author_channel_id,omitempty"`
CanRate *bool `yaml:"can_rate" json:"can_rate,omitempty"`
ParentId string `yaml:"parent_id" json:"parent_id,omitempty"`
TextFormat string `yaml:"text_format" json:"text_format,omitempty"`
TextOriginal string `yaml:"text_original" json:"text_original,omitempty"`
ModerationStatus string `yaml:"moderation_status" json:"moderation_status,omitempty"`
BanAuthor *bool `yaml:"ban_author" json:"ban_author,omitempty"`
VideoId string `yaml:"video_id" json:"video_id,omitempty"`
ViewerRating string `yaml:"viewer_rating" json:"viewer_rating,omitempty"`
}
type IComment[T any] interface {
Get() ([]*T, error)
List(io.Writer) error
Insert(io.Writer) error
Update(io.Writer) error
Delete(io.Writer) error
MarkAsSpam(io.Writer) error
SetModerationStatus(io.Writer) error
}
type Option func(*Comment)
func NewComment(opts ...Option) IComment[youtube.Comment] {
c := &Comment{Fields: &common.Fields{}}
for _, opt := range opts {
opt(c)
}
return c
}
func (c *Comment) Get() ([]*youtube.Comment, error) {
if err := c.EnsureService(); err != nil {
return nil, err
}
call := c.Service.Comments.List(c.Parts)
if len(c.Ids) > 0 && c.Ids[0] != "" {
call = call.Id(c.Ids...)
}
if c.ParentId != "" {
call = call.ParentId(c.ParentId)
}
if c.TextFormat != "" {
call = call.TextFormat(c.TextFormat)
}
return common.Paginate(
c.Fields, call,
func(r *youtube.CommentListResponse) ([]*youtube.Comment, string) {
return r.Items, r.NextPageToken
}, errGetComment,
)
}
func (c *Comment) List(writer io.Writer) error {
comments, err := c.Get()
if err != nil && comments == nil {
return err
}
common.PrintList(
c.Output, comments, writer,
table.Row{"ID", "Author", "Video ID", "Text Display"},
func(cm *youtube.Comment) table.Row {
author := ""
videoId := ""
textDisplay := ""
if cm.Snippet != nil {
author = cm.Snippet.AuthorDisplayName
videoId = cm.Snippet.VideoId
textDisplay = cm.Snippet.TextDisplay
}
return table.Row{cm.Id, author, videoId, textDisplay}
},
)
return err
}
func (c *Comment) Insert(writer io.Writer) error {
if err := c.EnsureService(); err != nil {
return err
}
comment := &youtube.Comment{
Snippet: &youtube.CommentSnippet{
AuthorChannelId: &youtube.CommentSnippetAuthorChannelId{
Value: c.AuthorChannelId,
},
ChannelId: c.ChannelId,
ParentId: c.ParentId,
TextOriginal: c.TextOriginal,
VideoId: c.VideoId,
},
}
if c.CanRate != nil {
comment.Snippet.CanRate = *c.CanRate
}
call := c.Service.Comments.Insert([]string{"snippet"}, comment)
res, err := call.Do()
if err != nil {
return errors.Join(errInsertComment, err)
}
common.PrintResult(c.Output, res, writer, "Comment inserted: %s\n", res.Id)
return nil
}
func (c *Comment) Update(writer io.Writer) error {
if err := c.EnsureService(); err != nil {
return err
}
c.Parts = []string{"id", "snippet"}
comments, err := c.Get()
if err != nil {
return errors.Join(errUpdateComment, err)
}
if len(comments) == 0 {
return errGetComment
}
comment := comments[0]
if c.CanRate != nil {
comment.Snippet.CanRate = *c.CanRate
}
if c.TextOriginal != "" {
comment.Snippet.TextOriginal = c.TextOriginal
}
if c.ViewerRating != "" {
comment.Snippet.ViewerRating = c.ViewerRating
}
call := c.Service.Comments.Update([]string{"snippet"}, comment)
res, err := call.Do()
if err != nil {
return errors.Join(errUpdateComment, err)
}
common.PrintResult(c.Output, res, writer, "Comment updated: %s\n", res.Id)
return nil
}
func (c *Comment) MarkAsSpam(writer io.Writer) error {
if err := c.EnsureService(); err != nil {
return err
}
call := c.Service.Comments.MarkAsSpam(c.Ids)
err := call.Do()
if err != nil {
return errors.Join(errMarkAsSpam, err)
}
common.PrintResult(c.Output, c, writer, "Comment marked as spam: %s\n", c.Ids)
return nil
}
func (c *Comment) SetModerationStatus(writer io.Writer) error {
if err := c.EnsureService(); err != nil {
return err
}
call := c.Service.Comments.SetModerationStatus(c.Ids, c.ModerationStatus)
if c.BanAuthor != nil {
call = call.BanAuthor(*c.BanAuthor)
}
err := call.Do()
if err != nil {
return errors.Join(errSetModerationStatus, err)
}
common.PrintResult(
c.Output, c, writer, "Comment moderation status set to %s: %s\n",
c.ModerationStatus, c.Ids,
)
return nil
}
func (c *Comment) Delete(writer io.Writer) error {
if err := c.EnsureService(); err != nil {
return err
}
for _, id := range c.Ids {
call := c.Service.Comments.Delete(id)
err := call.Do()
if err != nil {
return errors.Join(errDeleteComment, err)
}
_, _ = fmt.Fprintf(writer, "Comment %s deleted\n", id)
}
return nil
}
func WithAuthorChannelId(authorChannelId string) Option {
return func(c *Comment) {
c.AuthorChannelId = authorChannelId
}
}
func WithCanRate(canRate *bool) Option {
return func(c *Comment) {
if canRate != nil {
c.CanRate = canRate
}
}
}
func WithParentId(parentId string) Option {
return func(c *Comment) {
c.ParentId = parentId
}
}
func WithTextFormat(textFormat string) Option {
return func(c *Comment) {
c.TextFormat = textFormat
}
}
func WithTextOriginal(textOriginal string) Option {
return func(c *Comment) {
c.TextOriginal = textOriginal
}
}
func WithModerationStatus(moderationStatus string) Option {
return func(c *Comment) {
c.ModerationStatus = moderationStatus
}
}
func WithBanAuthor(banAuthor *bool) Option {
return func(c *Comment) {
if banAuthor != nil {
c.BanAuthor = banAuthor
}
}
}
func WithVideoId(videoId string) Option {
return func(c *Comment) {
c.VideoId = videoId
}
}
func WithViewerRating(viewerRating string) Option {
return func(c *Comment) {
c.ViewerRating = viewerRating
}
}
var (
WithParts = common.WithParts[*Comment]
WithOutput = common.WithOutput[*Comment]
WithService = common.WithService[*Comment]
WithIds = common.WithIds[*Comment]
WithMaxResults = common.WithMaxResults[*Comment]
WithChannelId = common.WithChannelId[*Comment]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package commentThread
import (
"errors"
"io"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetCommentThread = errors.New("failed to get comment thread")
errInsertCommentThread = errors.New("failed to insert comment thread")
)
type CommentThread struct {
*common.Fields
AuthorChannelId string `yaml:"author_channel_id" json:"author_channel_id,omitempty"`
ModerationStatus string `yaml:"moderation_status" json:"moderation_status,omitempty"`
Order string `yaml:"order" json:"order,omitempty"`
SearchTerms string `yaml:"search_terms" json:"search_terms,omitempty"`
TextFormat string `yaml:"text_format" json:"text_format,omitempty"`
TextOriginal string `yaml:"text_original" json:"text_original,omitempty"`
VideoId string `yaml:"video_id" json:"video_id,omitempty"`
AllThreadsRelatedToChannelId string `yaml:"all_threads_related_to_channel_id" json:"all_threads_related_to_channel_id,omitempty"`
}
type ICommentThread[T any] interface {
Get() ([]*T, error)
List(io.Writer) error
Insert(io.Writer) error
}
type Option func(*CommentThread)
func NewCommentThread(opts ...Option) ICommentThread[youtube.CommentThread] {
c := &CommentThread{Fields: &common.Fields{}}
for _, opt := range opts {
opt(c)
}
return c
}
func (c *CommentThread) Get() ([]*youtube.CommentThread, error) {
if err := c.EnsureService(); err != nil {
return nil, err
}
call := c.Service.CommentThreads.List(c.Parts)
if len(c.Ids) > 0 {
call = call.Id(c.Ids...)
}
if c.AllThreadsRelatedToChannelId != "" {
call = call.AllThreadsRelatedToChannelId(c.AllThreadsRelatedToChannelId)
}
if c.ChannelId != "" {
call = call.ChannelId(c.ChannelId)
}
if c.ModerationStatus != "" {
call = call.ModerationStatus(c.ModerationStatus)
}
if c.Order != "" {
call = call.Order(c.Order)
}
if c.SearchTerms != "" {
call = call.SearchTerms(c.SearchTerms)
}
if c.TextFormat != "" {
call = call.TextFormat(c.TextFormat)
}
if c.VideoId != "" {
call = call.VideoId(c.VideoId)
}
return common.Paginate(
c.Fields, call,
func(r *youtube.CommentThreadListResponse) ([]*youtube.CommentThread, string) {
return r.Items, r.NextPageToken
}, errGetCommentThread,
)
}
func (c *CommentThread) List(writer io.Writer) error {
commentThreads, err := c.Get()
if err != nil && commentThreads == nil {
return err
}
common.PrintList(
c.Output, commentThreads, writer,
table.Row{"ID", "Author", "Video ID", "Text Display"},
func(cot *youtube.CommentThread) table.Row {
snippet := cot.Snippet.TopLevelComment.Snippet
return table.Row{cot.Id, snippet.AuthorDisplayName, snippet.VideoId, snippet.TextDisplay}
},
)
return err
}
func (c *CommentThread) Insert(writer io.Writer) error {
if err := c.EnsureService(); err != nil {
return err
}
ct := &youtube.CommentThread{
Snippet: &youtube.CommentThreadSnippet{
ChannelId: c.ChannelId,
TopLevelComment: &youtube.Comment{
Snippet: &youtube.CommentSnippet{
AuthorChannelId: &youtube.CommentSnippetAuthorChannelId{
Value: c.AuthorChannelId,
},
ChannelId: c.ChannelId,
TextOriginal: c.TextOriginal,
VideoId: c.VideoId,
},
},
},
}
res, err := c.Service.CommentThreads.Insert([]string{"snippet"}, ct).Do()
if err != nil {
return errors.Join(errInsertCommentThread, err)
}
common.PrintResult(
c.Output, res, writer, "CommentThread inserted: %s\n", res.Id,
)
return nil
}
func WithAllThreadsRelatedToChannelId(allThreadsRelatedToChannelId string) Option {
return func(c *CommentThread) {
c.AllThreadsRelatedToChannelId = allThreadsRelatedToChannelId
}
}
func WithAuthorChannelId(authorChannelId string) Option {
return func(c *CommentThread) {
c.AuthorChannelId = authorChannelId
}
}
func WithModerationStatus(moderationStatus string) Option {
return func(c *CommentThread) {
c.ModerationStatus = moderationStatus
}
}
func WithOrder(order string) Option {
return func(c *CommentThread) {
c.Order = order
}
}
func WithSearchTerms(searchTerms string) Option {
return func(c *CommentThread) {
c.SearchTerms = searchTerms
}
}
func WithTextFormat(textFormat string) Option {
return func(c *CommentThread) {
c.TextFormat = textFormat
}
}
func WithTextOriginal(textOriginal string) Option {
return func(c *CommentThread) {
c.TextOriginal = textOriginal
}
}
func WithVideoId(videoId string) Option {
return func(c *CommentThread) {
c.VideoId = videoId
}
}
var (
WithParts = common.WithParts[*CommentThread]
WithOutput = common.WithOutput[*CommentThread]
WithService = common.WithService[*CommentThread]
WithIds = common.WithIds[*CommentThread]
WithMaxResults = common.WithMaxResults[*CommentThread]
WithChannelId = common.WithChannelId[*CommentThread]
)
// Copyright 2026 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package common
import (
"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"
"google.golang.org/api/googleapi"
"google.golang.org/api/youtube/v3"
)
type Fields struct {
Service *youtube.Service `yaml:"-" json:"-"`
Ids []string `yaml:"ids" json:"ids,omitempty"`
MaxResults int64 `yaml:"max_results" json:"max_results,omitempty"`
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
}
func (d *Fields) EnsureService() error {
if d.Service == nil {
svc, err := auth.NewY2BService(
auth.WithCredential("", pkg.Root.FS()),
auth.WithCacheToken("", pkg.Root.FS()),
).GetService()
if err != nil {
return fmt.Errorf("failed to create YouTube service: %w", err)
}
d.Service = svc
}
return 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
pageToken := ""
for f.MaxResults > 0 {
call = call.MaxResults(min(f.MaxResults, 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)
f.MaxResults -= pkg.PerPage
items = append(items, got...)
pageToken = nextToken
if pageToken == "" || len(got) == 0 {
break
}
}
return items, nil
}
// Copyright 2026 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package common
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"google.golang.org/api/option"
"google.golang.org/api/youtube/v3"
)
// NewTestService creates a youtube.Service backed by the given handler for testing.
// It registers cleanup of the test server automatically.
func NewTestService(t *testing.T, handler http.Handler) *youtube.Service {
t.Helper()
ts := httptest.NewServer(handler)
t.Cleanup(ts.Close)
svc, err := youtube.NewService(
context.Background(),
option.WithEndpoint(ts.URL),
option.WithAPIKey("test-key"),
)
if err != nil {
t.Fatalf("failed to create youtube service: %v", err)
}
return svc
}
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package i18nLanguage
import (
"errors"
"io"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetI18nLanguage = errors.New("failed to get i18n language")
)
type I18nLanguage struct {
*common.Fields
}
type II18nLanguage[T youtube.I18nLanguage] interface {
Get() ([]*T, error)
List(io.Writer) error
}
type Option func(*I18nLanguage)
func NewI18nLanguage(opts ...Option) II18nLanguage[youtube.I18nLanguage] {
i := &I18nLanguage{Fields: &common.Fields{}}
for _, opt := range opts {
opt(i)
}
return i
}
func (i *I18nLanguage) Get() (
[]*youtube.I18nLanguage, error,
) {
if err := i.EnsureService(); err != nil {
return nil, err
}
call := i.Service.I18nLanguages.List(i.Parts)
if i.Hl != "" {
call = call.Hl(i.Hl)
}
res, err := call.Do()
if err != nil {
return nil, errors.Join(errGetI18nLanguage, err)
}
return res.Items, nil
}
func (i *I18nLanguage) List(writer io.Writer) error {
languages, err := i.Get()
if err != nil {
return err
}
common.PrintList(
i.Output, languages, writer, table.Row{"ID", "Hl", "Name"},
func(l *youtube.I18nLanguage) table.Row {
return table.Row{l.Id, l.Snippet.Hl, l.Snippet.Name}
},
)
return nil
}
var (
WithHl = common.WithHl[*I18nLanguage]
WithParts = common.WithParts[*I18nLanguage]
WithOutput = common.WithOutput[*I18nLanguage]
WithService = common.WithService[*I18nLanguage]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package i18nRegion
import (
"errors"
"io"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetI18nRegion = errors.New("failed to get i18n region")
)
type I18nRegion struct {
*common.Fields
}
type II18nRegion[T any] interface {
Get() ([]*T, error)
List(io.Writer) error
}
type Option func(*I18nRegion)
func NewI18nRegion(opts ...Option) II18nRegion[youtube.I18nRegion] {
i := &I18nRegion{Fields: &common.Fields{}}
for _, opt := range opts {
opt(i)
}
return i
}
func (i *I18nRegion) Get() ([]*youtube.I18nRegion, error) {
if err := i.EnsureService(); err != nil {
return nil, err
}
call := i.Service.I18nRegions.List(i.Parts)
if i.Hl != "" {
call = call.Hl(i.Hl)
}
res, err := call.Do()
if err != nil {
return nil, errors.Join(errGetI18nRegion, err)
}
return res.Items, nil
}
func (i *I18nRegion) List(writer io.Writer) error {
regions, err := i.Get()
if err != nil {
return err
}
common.PrintList(
i.Output, regions, writer, table.Row{"ID", "Gl", "Name"},
func(r *youtube.I18nRegion) table.Row {
return table.Row{r.Id, r.Snippet.Gl, r.Snippet.Name}
},
)
return nil
}
var (
WithHl = common.WithHl[*I18nRegion]
WithParts = common.WithParts[*I18nRegion]
WithOutput = common.WithOutput[*I18nRegion]
WithService = common.WithService[*I18nRegion]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package pkg
import (
"log/slog"
"os"
"runtime"
)
var (
RootDir *string
Root *os.Root
logger *slog.Logger
)
func init() {
if logger == nil {
initLogger()
}
if RootDir == nil {
initRootDir()
}
}
func initLogger() {
logLevel := slog.LevelInfo
if lvl, ok := os.LookupEnv("YUTU_LOG_LEVEL"); ok {
switch lvl {
case "DEBUG", "debug":
logLevel = slog.LevelDebug
case "INFO", "info":
logLevel = slog.LevelInfo
case "WARN", "warn":
logLevel = slog.LevelWarn
case "ERROR", "error":
logLevel = slog.LevelError
}
}
logger = slog.New(
slog.NewTextHandler(
os.Stderr, &slog.HandlerOptions{
Level: logLevel,
},
),
)
slog.SetDefault(logger)
}
func initRootDir() {
var err error
rootDir, ok := os.LookupEnv("YUTU_ROOT")
if !ok {
rootDir, err = os.Getwd()
if err != nil {
rootDir = "/"
if runtime.GOOS == "windows" {
rootDir = os.Getenv("SystemDrive") + `\`
}
slog.Debug(getWdFailed, "error", err, "fallback", rootDir)
}
}
RootDir = &rootDir
Root, err = os.OpenRoot(*RootDir)
if err != nil {
slog.Error(openRootFailed, "dir", *RootDir, "error", err)
panic(err)
}
slog.Debug("Root directory set", "dir", *RootDir)
}
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package member
import (
"errors"
"io"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetMember = errors.New("failed to get member")
)
type Member struct {
*common.Fields
MemberChannelId string `yaml:"member_channel_id" json:"member_channel_id,omitempty"`
HasAccessToLevel string `yaml:"has_access_to_level" json:"has_access_to_level,omitempty"`
Mode string `yaml:"mode" json:"mode,omitempty"`
}
type IMember[T any] interface {
List(io.Writer) error
Get() ([]*T, error)
}
type Option func(*Member)
func NewMember(opts ...Option) IMember[youtube.Member] {
m := &Member{Fields: &common.Fields{}}
for _, opt := range opts {
opt(m)
}
return m
}
func (m *Member) Get() ([]*youtube.Member, error) {
if err := m.EnsureService(); err != nil {
return nil, err
}
call := m.Service.Members.List(m.Parts)
if m.MemberChannelId != "" {
call = call.FilterByMemberChannelId(m.MemberChannelId)
}
if m.HasAccessToLevel != "" {
call = call.HasAccessToLevel(m.HasAccessToLevel)
}
if m.Mode != "" {
call = call.Mode(m.Mode)
}
return common.Paginate(
m.Fields, call,
func(r *youtube.MemberListResponse) ([]*youtube.Member, string) {
return r.Items, r.NextPageToken
}, errGetMember,
)
}
func (m *Member) List(writer io.Writer) error {
members, err := m.Get()
if err != nil && members == nil {
return err
}
common.PrintList(
m.Output, members, writer, table.Row{"Channel ID", "Display Name"},
func(m *youtube.Member) table.Row {
return table.Row{m.Snippet.MemberDetails.ChannelId, m.Snippet.MemberDetails.DisplayName}
},
)
return err
}
func WithMemberChannelId(channelId string) Option {
return func(m *Member) {
m.MemberChannelId = channelId
}
}
func WithHasAccessToLevel(level string) Option {
return func(m *Member) {
m.HasAccessToLevel = level
}
}
func WithMode(mode string) Option {
return func(m *Member) {
m.Mode = mode
}
}
var (
WithMaxResults = common.WithMaxResults[*Member]
WithParts = common.WithParts[*Member]
WithOutput = common.WithOutput[*Member]
WithService = common.WithService[*Member]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package membershipsLevel
import (
"errors"
"io"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetMembershipsLevel = errors.New("failed to get memberships level")
)
type MembershipsLevel struct {
*common.Fields
}
type IMembershipsLevel[T any] interface {
List(io.Writer) error
Get() ([]*T, error)
}
type Option func(*MembershipsLevel)
func NewMembershipsLevel(opts ...Option) IMembershipsLevel[youtube.MembershipsLevel] {
m := &MembershipsLevel{Fields: &common.Fields{}}
for _, opt := range opts {
opt(m)
}
return m
}
func (m *MembershipsLevel) GetFields() *common.Fields {
return m.Fields
}
func (m *MembershipsLevel) Get() ([]*youtube.MembershipsLevel, error) {
if err := m.EnsureService(); err != nil {
return nil, err
}
call := m.Service.MembershipsLevels.List(m.Parts)
res, err := call.Do()
if err != nil {
return nil, errors.Join(errGetMembershipsLevel, err)
}
return res.Items, nil
}
func (m *MembershipsLevel) List(writer io.Writer) error {
levels, err := m.Get()
if err != nil {
return err
}
common.PrintList(
m.Output, levels, writer, table.Row{"ID", "Display Name"},
func(ml *youtube.MembershipsLevel) table.Row {
return table.Row{ml.Id, ml.Snippet.LevelDetails.DisplayName}
},
)
return nil
}
var (
WithParts = common.WithParts[*MembershipsLevel]
WithOutput = common.WithOutput[*MembershipsLevel]
WithService = common.WithService[*MembershipsLevel]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package playlist
import (
"errors"
"fmt"
"io"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetPlaylist = errors.New("failed to get playlist")
errInsertPlaylist = errors.New("failed to insert playlist")
errUpdatePlaylist = errors.New("failed to update playlist")
errDeletePlaylist = errors.New("failed to delete playlist")
)
type Playlist struct {
*common.Fields
Title string `yaml:"title" json:"title,omitempty"`
Description string `yaml:"description" json:"description,omitempty"`
Mine *bool `yaml:"mine" json:"mine,omitempty"`
Tags []string `yaml:"tags" json:"tags,omitempty"`
Language string `yaml:"language" json:"language,omitempty"`
Privacy string `yaml:"privacy" json:"privacy,omitempty"`
OnBehalfOfContentOwnerChannel string `yaml:"on_behalf_of_content_owner_channel" json:"on_behalf_of_content_owner_channel,omitempty"`
}
type IPlaylist[T any] interface {
List(io.Writer) error
Insert(io.Writer) error
Update(io.Writer) error
Delete(io.Writer) error
Get() ([]*T, error)
}
type Option func(*Playlist)
func NewPlaylist(opts ...Option) IPlaylist[youtube.Playlist] {
p := &Playlist{Fields: &common.Fields{}}
for _, opt := range opts {
opt(p)
}
return p
}
func (p *Playlist) Get() ([]*youtube.Playlist, error) {
if err := p.EnsureService(); err != nil {
return nil, err
}
call := p.Service.Playlists.List(p.Parts)
if len(p.Ids) > 0 {
call = call.Id(p.Ids...)
}
if p.ChannelId != "" {
call = call.ChannelId(p.ChannelId)
}
if p.Hl != "" {
call = call.Hl(p.Hl)
}
if p.Mine != nil {
call = call.Mine(*p.Mine)
}
if p.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(p.OnBehalfOfContentOwner)
}
if p.OnBehalfOfContentOwnerChannel != "" {
call = call.OnBehalfOfContentOwnerChannel(p.OnBehalfOfContentOwnerChannel)
}
return common.Paginate(
p.Fields, call,
func(r *youtube.PlaylistListResponse) ([]*youtube.Playlist, string) {
return r.Items, r.NextPageToken
}, errGetPlaylist,
)
}
func (p *Playlist) List(writer io.Writer) error {
playlists, err := p.Get()
if err != nil && playlists == nil {
return err
}
common.PrintList(
p.Output, playlists, writer, table.Row{"ID", "Channel ID", "Title"},
func(pl *youtube.Playlist) table.Row {
channelId := ""
title := ""
if pl.Snippet != nil {
channelId = pl.Snippet.ChannelId
title = pl.Snippet.Title
}
return table.Row{pl.Id, channelId, title}
},
)
return err
}
func (p *Playlist) Insert(writer io.Writer) error {
if err := p.EnsureService(); err != nil {
return err
}
upload := &youtube.Playlist{
Snippet: &youtube.PlaylistSnippet{
Title: p.Title,
Description: p.Description,
Tags: p.Tags,
DefaultLanguage: p.Language,
ChannelId: p.ChannelId,
},
Status: &youtube.PlaylistStatus{
PrivacyStatus: p.Privacy,
},
}
call := p.Service.Playlists.Insert([]string{"snippet", "status"}, upload)
if p.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(p.OnBehalfOfContentOwner)
}
if p.OnBehalfOfContentOwnerChannel != "" {
call = call.OnBehalfOfContentOwnerChannel(p.OnBehalfOfContentOwnerChannel)
}
res, err := call.Do()
if err != nil {
return errors.Join(errInsertPlaylist, err)
}
common.PrintResult(p.Output, res, writer, "Playlist inserted: %s\n", res.Id)
return nil
}
func (p *Playlist) Update(writer io.Writer) error {
if err := p.EnsureService(); err != nil {
return err
}
playlists, err := p.Get()
if err != nil {
return errors.Join(errUpdatePlaylist, err)
}
if len(playlists) == 0 {
return errGetPlaylist
}
playlist := playlists[0]
if p.Title != "" {
playlist.Snippet.Title = p.Title
}
if p.Description != "" {
playlist.Snippet.Description = p.Description
}
if p.Tags != nil {
playlist.Snippet.Tags = p.Tags
}
if p.Language != "" {
playlist.Snippet.DefaultLanguage = p.Language
}
if p.Privacy != "" {
playlist.Status.PrivacyStatus = p.Privacy
}
call := p.Service.Playlists.Update([]string{"snippet", "status"}, playlist)
if p.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(p.OnBehalfOfContentOwner)
}
res, err := call.Do()
if err != nil {
return errors.Join(errUpdatePlaylist, err)
}
common.PrintResult(p.Output, res, writer, "Playlist updated: %s\n", res.Id)
return nil
}
func (p *Playlist) Delete(writer io.Writer) error {
if err := p.EnsureService(); err != nil {
return err
}
for _, id := range p.Ids {
call := p.Service.Playlists.Delete(id)
if p.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(p.OnBehalfOfContentOwner)
}
err := call.Do()
if err != nil {
return errors.Join(errDeletePlaylist, err)
}
_, _ = fmt.Fprintf(writer, "Playlist %s deleted", id)
}
return nil
}
func WithTitle(title string) Option {
return func(p *Playlist) {
p.Title = title
}
}
func WithDescription(description string) Option {
return func(p *Playlist) {
p.Description = description
}
}
func WithTags(tags []string) Option {
return func(p *Playlist) {
p.Tags = tags
}
}
func WithLanguage(language string) Option {
return func(p *Playlist) {
p.Language = language
}
}
func WithPrivacy(privacy string) Option {
return func(p *Playlist) {
p.Privacy = privacy
}
}
func WithMine(mine *bool) Option {
return func(p *Playlist) {
if mine != nil {
p.Mine = mine
}
}
}
func WithOnBehalfOfContentOwnerChannel(channel string) Option {
return func(p *Playlist) {
p.OnBehalfOfContentOwnerChannel = channel
}
}
var (
WithParts = common.WithParts[*Playlist]
WithOutput = common.WithOutput[*Playlist]
WithService = common.WithService[*Playlist]
WithIds = common.WithIds[*Playlist]
WithMaxResults = common.WithMaxResults[*Playlist]
WithHl = common.WithHl[*Playlist]
WithChannelId = common.WithChannelId[*Playlist]
WithOnBehalfOfContentOwner = common.WithOnBehalfOfContentOwner[*Playlist]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package playlistImage
import (
"errors"
"fmt"
"io"
"os"
"github.com/eat-pray-ai/yutu/pkg"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetPlaylistImage = errors.New("failed to get playlist image")
errInsertPlaylistImage = errors.New("failed to insert playlist image")
errUpdatePlaylistImage = errors.New("failed to update playlist image")
errDeletePlaylistImage = errors.New("failed to delete playlist image")
)
type PlaylistImage struct {
*common.Fields
Height int64 `yaml:"height" json:"height,omitempty"`
PlaylistId string `yaml:"playlist_id" json:"playlist_id,omitempty"`
Type string `yaml:"type" json:"type,omitempty"`
Width int64 `yaml:"width" json:"width,omitempty"`
File string `yaml:"file" json:"file,omitempty"`
Parent string `yaml:"parent" json:"parent,omitempty"`
OnBehalfOfContentOwnerChannel string `yaml:"on_behalf_of_content_owner_channel" json:"on_behalf_of_content_owner_channel,omitempty"`
}
type IPlaylistImage[T any] interface {
Get() ([]*T, error)
List(io.Writer) error
Insert(io.Writer) error
Update(io.Writer) error
Delete(io.Writer) error
}
type Option func(*PlaylistImage)
func NewPlaylistImage(opts ...Option) IPlaylistImage[youtube.PlaylistImage] {
pi := &PlaylistImage{Fields: &common.Fields{}}
for _, opt := range opts {
opt(pi)
}
return pi
}
func (pi *PlaylistImage) Get() ([]*youtube.PlaylistImage, error) {
if err := pi.EnsureService(); err != nil {
return nil, err
}
call := pi.Service.PlaylistImages.List()
call = call.Part(pi.Parts...)
if pi.Parent != "" {
call = call.Parent(pi.Parent)
}
if pi.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(pi.OnBehalfOfContentOwner)
}
if pi.OnBehalfOfContentOwnerChannel != "" {
call = call.OnBehalfOfContentOwnerChannel(pi.OnBehalfOfContentOwnerChannel)
}
return common.Paginate(
pi.Fields, call,
func(r *youtube.PlaylistImageListResponse) ([]*youtube.PlaylistImage, string) {
return r.Items, r.NextPageToken
}, errGetPlaylistImage,
)
}
func (pi *PlaylistImage) List(writer io.Writer) error {
playlistImages, err := pi.Get()
if err != nil && playlistImages == nil {
return err
}
common.PrintList(
pi.Output, playlistImages, writer,
table.Row{"ID", "Kind", "Playlist ID", "Type"},
func(img *youtube.PlaylistImage) table.Row {
return table.Row{img.Id, img.Kind, img.Snippet.PlaylistId, img.Snippet.Type}
},
)
return err
}
func (pi *PlaylistImage) Insert(writer io.Writer) error {
if err := pi.EnsureService(); err != nil {
return err
}
file, err := pkg.Root.Open(pi.File)
if err != nil {
return errors.Join(errInsertPlaylistImage, err)
}
defer func(file *os.File) {
_ = file.Close()
}(file)
playlistImage := &youtube.PlaylistImage{
Kind: "youtube#playlistImages",
Snippet: &youtube.PlaylistImageSnippet{
PlaylistId: pi.PlaylistId,
Type: pi.Type,
Height: pi.Height,
Width: pi.Width,
},
}
call := pi.Service.PlaylistImages.Insert(playlistImage)
if pi.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(pi.OnBehalfOfContentOwner)
}
if pi.OnBehalfOfContentOwnerChannel != "" {
call = call.OnBehalfOfContentOwnerChannel(pi.OnBehalfOfContentOwnerChannel)
}
call = call.Media(file)
call = call.Part("kind", "snippet")
res, err := call.Do()
if err != nil {
return errors.Join(errInsertPlaylistImage, err)
}
common.PrintResult(
pi.Output, res, writer, "PlaylistImage inserted: %s\n", res.Id,
)
return nil
}
func (pi *PlaylistImage) Update(writer io.Writer) error {
if err := pi.EnsureService(); err != nil {
return err
}
pi.Parts = []string{"id", "kind", "snippet"}
playlistImages, err := pi.Get()
if err != nil {
return errors.Join(errUpdatePlaylistImage, err)
}
if len(playlistImages) == 0 {
return errGetPlaylistImage
}
playlistImage := playlistImages[0]
if pi.PlaylistId != "" {
playlistImage.Snippet.PlaylistId = pi.PlaylistId
}
if pi.Type != "" {
playlistImage.Snippet.Type = pi.Type
}
if pi.Height != 0 {
playlistImage.Snippet.Height = pi.Height
}
if pi.Width != 0 {
playlistImage.Snippet.Width = pi.Width
}
call := pi.Service.PlaylistImages.Update(playlistImage)
if pi.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(pi.OnBehalfOfContentOwner)
}
if pi.File != "" {
file, err := pkg.Root.Open(pi.File)
if err != nil {
return errors.Join(errUpdatePlaylistImage, err)
}
defer func(file *os.File) {
_ = file.Close()
}(file)
call = call.Media(file)
}
call = call.Part("id", "kind", "snippet")
res, err := call.Do()
if err != nil {
return errors.Join(errUpdatePlaylistImage, err)
}
common.PrintResult(
pi.Output, res, writer, "PlaylistImage updated: %s\n", res.Id,
)
return nil
}
func (pi *PlaylistImage) Delete(writer io.Writer) error {
if err := pi.EnsureService(); err != nil {
return err
}
for _, id := range pi.Ids {
call := pi.Service.PlaylistImages.Delete()
call = call.Id(id)
if pi.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(pi.OnBehalfOfContentOwner)
}
err := call.Do()
if err != nil {
return errors.Join(errDeletePlaylistImage, err)
}
_, _ = fmt.Fprintf(writer, "PlaylistImage %s deleted\n", id)
}
return nil
}
func WithHeight(height int64) Option {
return func(pi *PlaylistImage) {
pi.Height = height
}
}
func WithPlaylistId(playlistId string) Option {
return func(pi *PlaylistImage) {
pi.PlaylistId = playlistId
}
}
func WithType(t string) Option {
return func(pi *PlaylistImage) {
pi.Type = t
}
}
func WithWidth(width int64) Option {
return func(pi *PlaylistImage) {
pi.Width = width
}
}
func WithFile(file string) Option {
return func(pi *PlaylistImage) {
pi.File = file
}
}
func WithParent(parent string) Option {
return func(pi *PlaylistImage) {
pi.Parent = parent
}
}
func WithOnBehalfOfContentOwnerChannel(onBehalfOfContentOwnerChannel string) Option {
return func(pi *PlaylistImage) {
pi.OnBehalfOfContentOwnerChannel = onBehalfOfContentOwnerChannel
}
}
var (
WithParts = common.WithParts[*PlaylistImage]
WithOutput = common.WithOutput[*PlaylistImage]
WithService = common.WithService[*PlaylistImage]
WithIds = common.WithIds[*PlaylistImage]
WithMaxResults = common.WithMaxResults[*PlaylistImage]
WithOnBehalfOfContentOwner = common.WithOnBehalfOfContentOwner[*PlaylistImage]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package playlistItem
import (
"errors"
"fmt"
"io"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetPlaylistItem = errors.New("failed to get playlist item")
errUpdatePlaylistItem = errors.New("failed to update playlist item")
errInsertPlaylistItem = errors.New("failed to insert playlist item")
errDeletePlaylistItem = errors.New("failed to delete playlist item")
)
type PlaylistItem struct {
*common.Fields
Title string `yaml:"title" json:"title,omitempty"`
Description string `yaml:"description" json:"description,omitempty"`
Kind string `yaml:"kind" json:"kind,omitempty"`
KVideoId string `yaml:"k_video_id" json:"k_video_id,omitempty"`
KChannelId string `yaml:"k_channel_id" json:"k_channel_id,omitempty"`
KPlaylistId string `yaml:"k_playlist_id" json:"k_playlist_id,omitempty"`
VideoId string `yaml:"video_id" json:"video_id,omitempty"`
PlaylistId string `yaml:"playlist_id" json:"playlist_id,omitempty"`
Privacy string `yaml:"privacy" json:"privacy,omitempty"`
}
type IPlaylistItem[T any] interface {
List(io.Writer) error
Insert(io.Writer) error
Update(io.Writer) error
Delete(io.Writer) error
Get() ([]*T, error)
}
type Option func(*PlaylistItem)
func NewPlaylistItem(opts ...Option) IPlaylistItem[youtube.PlaylistItem] {
p := &PlaylistItem{Fields: &common.Fields{}}
for _, opt := range opts {
opt(p)
}
return p
}
func (pi *PlaylistItem) Get() ([]*youtube.PlaylistItem, error) {
if err := pi.EnsureService(); err != nil {
return nil, err
}
call := pi.Service.PlaylistItems.List(pi.Parts)
if len(pi.Ids) > 0 {
call = call.Id(pi.Ids...)
}
if pi.PlaylistId != "" {
call = call.PlaylistId(pi.PlaylistId)
}
if pi.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(pi.OnBehalfOfContentOwner)
}
if pi.VideoId != "" {
call = call.VideoId(pi.VideoId)
}
return common.Paginate(
pi.Fields, call,
func(r *youtube.PlaylistItemListResponse) ([]*youtube.PlaylistItem, string) {
return r.Items, r.NextPageToken
}, errGetPlaylistItem,
)
}
func (pi *PlaylistItem) List(writer io.Writer) error {
playlistItems, err := pi.Get()
if err != nil && playlistItems == nil {
return err
}
common.PrintList(
pi.Output, playlistItems, writer,
table.Row{"ID", "Title", "Kind", "Resource ID"},
func(item *youtube.PlaylistItem) table.Row {
title := ""
kind := ""
resourceId := ""
if item.Snippet != nil {
title = item.Snippet.Title
if item.Snippet.ResourceId != nil {
kind = item.Snippet.ResourceId.Kind
switch kind {
case "youtube#video":
resourceId = item.Snippet.ResourceId.VideoId
case "youtube#channel":
resourceId = item.Snippet.ResourceId.ChannelId
case "youtube#playlist":
resourceId = item.Snippet.ResourceId.PlaylistId
}
}
}
return table.Row{item.Id, title, kind, resourceId}
},
)
return err
}
func (pi *PlaylistItem) Insert(writer io.Writer) error {
if err := pi.EnsureService(); err != nil {
return err
}
var resourceId *youtube.ResourceId
switch pi.Kind {
case "video":
resourceId = &youtube.ResourceId{
Kind: "youtube#video",
VideoId: pi.KVideoId,
}
case "channel":
resourceId = &youtube.ResourceId{
Kind: "youtube#channel",
ChannelId: pi.KChannelId,
}
case "playlist":
resourceId = &youtube.ResourceId{
Kind: "youtube#playlist",
PlaylistId: pi.KPlaylistId,
}
}
playlistItem := &youtube.PlaylistItem{
Snippet: &youtube.PlaylistItemSnippet{
Title: pi.Title,
Description: pi.Description,
ResourceId: resourceId,
PlaylistId: pi.PlaylistId,
ChannelId: pi.ChannelId,
},
Status: &youtube.PlaylistItemStatus{
PrivacyStatus: pi.Privacy,
},
}
call := pi.Service.PlaylistItems.Insert(
[]string{"snippet", "status"}, playlistItem,
)
if pi.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(pi.OnBehalfOfContentOwner)
}
res, err := call.Do()
if err != nil {
return errors.Join(errInsertPlaylistItem, err)
}
common.PrintResult(
pi.Output, res, writer, "Playlist Item inserted: %s\n", res.Id,
)
return nil
}
func (pi *PlaylistItem) Update(writer io.Writer) error {
if err := pi.EnsureService(); err != nil {
return err
}
pi.Parts = []string{"id", "snippet", "status"}
playlistItems, err := pi.Get()
if err != nil {
return errors.Join(errUpdatePlaylistItem, err)
}
if len(playlistItems) == 0 {
return errGetPlaylistItem
}
playlistItem := playlistItems[0]
if pi.Title != "" {
playlistItem.Snippet.Title = pi.Title
}
if pi.Description != "" {
playlistItem.Snippet.Description = pi.Description
}
if pi.Privacy != "" {
playlistItem.Status.PrivacyStatus = pi.Privacy
}
call := pi.Service.PlaylistItems.Update(
[]string{"snippet", "status"}, playlistItem,
)
if pi.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(pi.OnBehalfOfContentOwner)
}
res, err := call.Do()
if err != nil {
return errors.Join(errUpdatePlaylistItem, err)
}
common.PrintResult(
pi.Output, res, writer, "Playlist Item updated: %s\n", res.Id,
)
return nil
}
func (pi *PlaylistItem) Delete(writer io.Writer) error {
if err := pi.EnsureService(); err != nil {
return err
}
for _, id := range pi.Ids {
call := pi.Service.PlaylistItems.Delete(id)
if pi.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(pi.OnBehalfOfContentOwner)
}
err := call.Do()
if err != nil {
return errors.Join(errDeletePlaylistItem, err)
}
_, _ = fmt.Fprintf(writer, "Playlist Item %s deleted\n", id)
}
return nil
}
func WithTitle(title string) Option {
return func(p *PlaylistItem) {
p.Title = title
}
}
func WithDescription(description string) Option {
return func(p *PlaylistItem) {
p.Description = description
}
}
func WithKind(kind string) Option {
return func(p *PlaylistItem) {
p.Kind = kind
}
}
func WithKVideoId(kVideoId string) Option {
return func(p *PlaylistItem) {
p.KVideoId = kVideoId
}
}
func WithKChannelId(kChannelId string) Option {
return func(p *PlaylistItem) {
p.KChannelId = kChannelId
}
}
func WithKPlaylistId(kPlaylistId string) Option {
return func(p *PlaylistItem) {
p.KPlaylistId = kPlaylistId
}
}
func WithVideoId(videoId string) Option {
return func(p *PlaylistItem) {
p.VideoId = videoId
}
}
func WithPlaylistId(playlistId string) Option {
return func(p *PlaylistItem) {
p.PlaylistId = playlistId
}
}
func WithPrivacy(privacy string) Option {
return func(p *PlaylistItem) {
p.Privacy = privacy
}
}
var (
WithParts = common.WithParts[*PlaylistItem]
WithOutput = common.WithOutput[*PlaylistItem]
WithService = common.WithService[*PlaylistItem]
WithIds = common.WithIds[*PlaylistItem]
WithMaxResults = common.WithMaxResults[*PlaylistItem]
WithChannelId = common.WithChannelId[*PlaylistItem]
WithOnBehalfOfContentOwner = common.WithOnBehalfOfContentOwner[*PlaylistItem]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package search
import (
"errors"
"io"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetSearch = errors.New("failed to get search")
)
type Search struct {
*common.Fields
ChannelType string `yaml:"channel_type" json:"channel_type,omitempty"`
EventType string `yaml:"event_type" json:"event_type,omitempty"`
ForContentOwner *bool `yaml:"for_content_owner" json:"for_content_owner,omitempty"`
ForDeveloper *bool `yaml:"for_developer" json:"for_developer,omitempty"`
ForMine *bool `yaml:"for_mine" json:"for_mine,omitempty"`
Location string `yaml:"location" json:"location,omitempty"`
LocationRadius string `yaml:"location_radius" json:"location_radius,omitempty"`
Order string `yaml:"order" json:"order,omitempty"`
PublishedAfter string `yaml:"published_after" json:"published_after,omitempty"`
PublishedBefore string `yaml:"published_before" json:"published_before,omitempty"`
Q string `yaml:"q" json:"q,omitempty"`
RegionCode string `yaml:"region_code" json:"region_code,omitempty"`
RelevanceLanguage string `yaml:"relevance_language" json:"relevance_language,omitempty"`
SafeSearch string `yaml:"safe_search" json:"safe_search,omitempty"`
TopicId string `yaml:"topic_id" json:"topic_id,omitempty"`
Types []string `yaml:"types" json:"types,omitempty"`
VideoCaption string `yaml:"video_caption" json:"video_caption,omitempty"`
VideoCategoryId string `yaml:"video_category_id" json:"video_category_id,omitempty"`
VideoDefinition string `yaml:"video_definition" json:"video_definition,omitempty"`
VideoDimension string `yaml:"video_dimension" json:"video_dimension,omitempty"`
VideoDuration string `yaml:"video_duration" json:"video_duration,omitempty"`
VideoEmbeddable string `yaml:"video_embeddable" json:"video_embeddable,omitempty"`
VideoLicense string `yaml:"video_license" json:"video_license,omitempty"`
VideoPaidProductPlacement string `yaml:"video_paid_product_placement" json:"video_paid_product_placement,omitempty"`
VideoSyndicated string `yaml:"video_syndicated" json:"video_syndicated,omitempty"`
VideoType string `yaml:"video_type" json:"video_type,omitempty"`
}
type ISearch[T any] interface {
Get() ([]*T, error)
List(io.Writer) error
}
type Option func(*Search)
func NewSearch(opts ...Option) ISearch[youtube.SearchResult] {
s := &Search{Fields: &common.Fields{}}
for _, opt := range opts {
opt(s)
}
return s
}
func (s *Search) Get() ([]*youtube.SearchResult, error) {
if err := s.EnsureService(); err != nil {
return nil, err
}
call := s.Service.Search.List(s.Parts)
if s.ChannelId != "" {
call = call.ChannelId(s.ChannelId)
}
if s.ChannelType != "" {
call = call.ChannelType(s.ChannelType)
}
if s.EventType != "" {
call = call.EventType(s.EventType)
}
if s.ForContentOwner != nil {
call = call.ForContentOwner(*s.ForContentOwner)
}
if s.ForDeveloper != nil {
call = call.ForDeveloper(*s.ForDeveloper)
}
if s.ForMine != nil {
call = call.ForMine(*s.ForMine)
}
if s.Location != "" {
call = call.Location(s.Location)
}
if s.LocationRadius != "" {
call = call.LocationRadius(s.LocationRadius)
}
if s.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(s.OnBehalfOfContentOwner)
}
if s.Order != "" {
call = call.Order(s.Order)
}
if s.PublishedAfter != "" {
call = call.PublishedAfter(s.PublishedAfter)
}
if s.PublishedBefore != "" {
call = call.PublishedBefore(s.PublishedBefore)
}
if s.Q != "" {
call = call.Q(s.Q)
}
if s.RegionCode != "" {
call = call.RegionCode(s.RegionCode)
}
if s.RelevanceLanguage != "" {
call = call.RelevanceLanguage(s.RelevanceLanguage)
}
if s.SafeSearch != "" {
call = call.SafeSearch(s.SafeSearch)
}
if s.TopicId != "" {
call = call.TopicId(s.TopicId)
}
if len(s.Types) > 0 {
call = call.Type(s.Types...)
}
if s.VideoCaption != "" {
call = call.VideoCaption(s.VideoCaption)
}
if s.VideoCategoryId != "" {
call = call.VideoCategoryId(s.VideoCategoryId)
}
if s.VideoDefinition != "" {
call = call.VideoDefinition(s.VideoDefinition)
}
if s.VideoDimension != "" {
call = call.VideoDimension(s.VideoDimension)
}
if s.VideoDuration != "" {
call = call.VideoDuration(s.VideoDuration)
}
if s.VideoEmbeddable != "" {
call = call.VideoEmbeddable(s.VideoEmbeddable)
}
if s.VideoLicense != "" {
call = call.VideoLicense(s.VideoLicense)
}
if s.VideoPaidProductPlacement != "" {
call = call.VideoPaidProductPlacement(s.VideoPaidProductPlacement)
}
if s.VideoSyndicated != "" {
call = call.VideoSyndicated(s.VideoSyndicated)
}
if s.VideoType != "" {
call = call.VideoType(s.VideoType)
}
return common.Paginate(
s.Fields, call,
func(r *youtube.SearchListResponse) ([]*youtube.SearchResult, string) {
return r.Items, r.NextPageToken
}, errGetSearch,
)
}
func (s *Search) List(writer io.Writer) error {
results, err := s.Get()
if err != nil && results == nil {
return err
}
common.PrintList(
s.Output, results, writer, table.Row{"Kind", "Title", "Resource ID"},
func(r *youtube.SearchResult) table.Row {
var resourceId string
switch r.Id.Kind {
case "youtube#video":
resourceId = r.Id.VideoId
case "youtube#channel":
resourceId = r.Id.ChannelId
case "youtube#playlist":
resourceId = r.Id.PlaylistId
}
return table.Row{r.Id.Kind, r.Snippet.Title, resourceId}
},
)
return err
}
func WithChannelType(channelType string) Option {
return func(s *Search) {
s.ChannelType = channelType
}
}
func WithEventType(eventType string) Option {
return func(s *Search) {
s.EventType = eventType
}
}
func WithForContentOwner(forContentOwner *bool) Option {
return func(s *Search) {
if forContentOwner != nil {
s.ForContentOwner = forContentOwner
}
}
}
func WithForDeveloper(forDeveloper *bool) Option {
return func(s *Search) {
if forDeveloper != nil {
s.ForDeveloper = forDeveloper
}
}
}
func WithForMine(forMine *bool) Option {
return func(s *Search) {
if forMine != nil {
s.ForMine = forMine
}
}
}
func WithLocation(location string) Option {
return func(s *Search) {
s.Location = location
}
}
func WithLocationRadius(locationRadius string) Option {
return func(s *Search) {
s.LocationRadius = locationRadius
}
}
func WithOrder(order string) Option {
return func(s *Search) {
s.Order = order
}
}
func WithPublishedAfter(publishedAfter string) Option {
return func(s *Search) {
s.PublishedAfter = publishedAfter
}
}
func WithPublishedBefore(publishedBefore string) Option {
return func(s *Search) {
s.PublishedBefore = publishedBefore
}
}
func WithQ(q string) Option {
return func(s *Search) {
s.Q = q
}
}
func WithRegionCode(regionCode string) Option {
return func(s *Search) {
s.RegionCode = regionCode
}
}
func WithRelevanceLanguage(relevanceLanguage string) Option {
return func(s *Search) {
s.RelevanceLanguage = relevanceLanguage
}
}
func WithSafeSearch(safeSearch string) Option {
return func(s *Search) {
s.SafeSearch = safeSearch
}
}
func WithTopicId(topicId string) Option {
return func(s *Search) {
s.TopicId = topicId
}
}
func WithTypes(types []string) Option {
return func(s *Search) {
s.Types = types
}
}
func WithVideoCaption(videoCaption string) Option {
return func(s *Search) {
s.VideoCaption = videoCaption
}
}
func WithVideoCategoryId(videoCategoryId string) Option {
return func(s *Search) {
s.VideoCategoryId = videoCategoryId
}
}
func WithVideoDefinition(videoDefinition string) Option {
return func(s *Search) {
s.VideoDefinition = videoDefinition
}
}
func WithVideoDimension(videoDimension string) Option {
return func(s *Search) {
s.VideoDimension = videoDimension
}
}
func WithVideoDuration(videoDuration string) Option {
return func(s *Search) {
s.VideoDuration = videoDuration
}
}
func WithVideoEmbeddable(videoEmbeddable string) Option {
return func(s *Search) {
s.VideoEmbeddable = videoEmbeddable
}
}
func WithVideoLicense(videoLicense string) Option {
return func(s *Search) {
s.VideoLicense = videoLicense
}
}
func WithVideoPaidProductPlacement(videoPaidProductPlacement string) Option {
return func(s *Search) {
s.VideoPaidProductPlacement = videoPaidProductPlacement
}
}
func WithVideoSyndicated(videoSyndicated string) Option {
return func(s *Search) {
s.VideoSyndicated = videoSyndicated
}
}
func WithVideoType(videoType string) Option {
return func(s *Search) {
s.VideoType = videoType
}
}
var (
WithParts = common.WithParts[*Search]
WithOutput = common.WithOutput[*Search]
WithService = common.WithService[*Search]
WithMaxResults = common.WithMaxResults[*Search]
WithChannelId = common.WithChannelId[*Search]
WithOnBehalfOfContentOwner = common.WithOnBehalfOfContentOwner[*Search]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package subscription
import (
"errors"
"fmt"
"io"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetSubscription = errors.New("failed to get subscription")
errDeleteSubscription = errors.New("failed to delete subscription")
errInsertSubscription = errors.New("failed to insert subscription")
)
type Subscription struct {
*common.Fields
SubscriberChannelId string `yaml:"subscriber_channel_id" json:"subscriber_channel_id,omitempty"`
Description string `yaml:"description" json:"description,omitempty"`
ForChannelId string `yaml:"for_channel_id" json:"for_channel_id,omitempty"`
Mine *bool `yaml:"mine" json:"mine,omitempty"`
MyRecentSubscribers *bool `yaml:"my_recent_subscribers" json:"my_recent_subscribers,omitempty"`
MySubscribers *bool `yaml:"my_subscribers" json:"my_subscribers,omitempty"`
Order string `yaml:"order" json:"order,omitempty"`
Title string `yaml:"title" json:"title,omitempty"`
OnBehalfOfContentOwnerChannel string `yaml:"on_behalf_of_content_owner_channel" json:"on_behalf_of_content_owner_channel,omitempty"`
}
type ISubscription[T any] interface {
Get() ([]*T, error)
List(io.Writer) error
Insert(io.Writer) error
Delete(io.Writer) error
}
type Option func(*Subscription)
func NewSubscription(opts ...Option) ISubscription[youtube.Subscription] {
s := &Subscription{Fields: &common.Fields{}}
for _, opt := range opts {
opt(s)
}
return s
}
func (s *Subscription) Get() ([]*youtube.Subscription, error) {
if err := s.EnsureService(); err != nil {
return nil, err
}
call := s.Service.Subscriptions.List(s.Parts)
if len(s.Ids) > 0 {
call = call.Id(s.Ids...)
}
if s.ChannelId != "" {
call = call.ChannelId(s.ChannelId)
}
if s.ForChannelId != "" {
call = call.ForChannelId(s.ForChannelId)
}
if s.Mine != nil {
call = call.Mine(*s.Mine)
}
if s.MyRecentSubscribers != nil {
call = call.MyRecentSubscribers(*s.MyRecentSubscribers)
}
if s.MySubscribers != nil {
call = call.MySubscribers(*s.MySubscribers)
}
if s.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(s.OnBehalfOfContentOwner)
}
if s.OnBehalfOfContentOwnerChannel != "" {
call = call.OnBehalfOfContentOwnerChannel(s.OnBehalfOfContentOwnerChannel)
}
if s.Order != "" {
call = call.Order(s.Order)
}
return common.Paginate(
s.Fields, call,
func(r *youtube.SubscriptionListResponse) ([]*youtube.Subscription, string) {
return r.Items, r.NextPageToken
}, errGetSubscription,
)
}
func (s *Subscription) List(writer io.Writer) error {
subscriptions, err := s.Get()
if err != nil && subscriptions == nil {
return err
}
common.PrintList(
s.Output, subscriptions, writer,
table.Row{"ID", "Kind", "Resource ID", "Channel Title"},
func(sub *youtube.Subscription) table.Row {
var resourceId string
switch sub.Snippet.ResourceId.Kind {
case "youtube#video":
resourceId = sub.Snippet.ResourceId.VideoId
case "youtube#channel":
resourceId = sub.Snippet.ResourceId.ChannelId
case "youtube#playlist":
resourceId = sub.Snippet.ResourceId.PlaylistId
}
return table.Row{sub.Id, sub.Snippet.ResourceId.Kind, resourceId, sub.Snippet.Title}
},
)
return err
}
func (s *Subscription) Insert(writer io.Writer) error {
if err := s.EnsureService(); err != nil {
return err
}
subscription := &youtube.Subscription{
Snippet: &youtube.SubscriptionSnippet{
ChannelId: s.SubscriberChannelId,
Description: s.Description,
ResourceId: &youtube.ResourceId{
ChannelId: s.ChannelId,
},
Title: s.Title,
},
}
call := s.Service.Subscriptions.Insert([]string{"snippet"}, subscription)
res, err := call.Do()
if err != nil {
return errors.Join(errInsertSubscription, err)
}
common.PrintResult(
s.Output, res, writer, "Subscription inserted: %s\n", res.Id,
)
return nil
}
func (s *Subscription) Delete(writer io.Writer) error {
if err := s.EnsureService(); err != nil {
return err
}
for _, id := range s.Ids {
call := s.Service.Subscriptions.Delete(id)
err := call.Do()
if err != nil {
return errors.Join(errDeleteSubscription, err)
}
_, _ = fmt.Fprintf(writer, "Subscription %s deleted", id)
}
return nil
}
func WithSubscriberChannelId(id string) Option {
return func(s *Subscription) {
s.SubscriberChannelId = id
}
}
func WithDescription(description string) Option {
return func(s *Subscription) {
s.Description = description
}
}
func WithForChannelId(forChannelId string) Option {
return func(s *Subscription) {
s.ForChannelId = forChannelId
}
}
func WithMine(mine *bool) Option {
return func(s *Subscription) {
if mine != nil {
s.Mine = mine
}
}
}
func WithMyRecentSubscribers(myRecentSubscribers *bool) Option {
return func(s *Subscription) {
if myRecentSubscribers != nil {
s.MyRecentSubscribers = myRecentSubscribers
}
}
}
func WithMySubscribers(mySubscribers *bool) Option {
return func(s *Subscription) {
if mySubscribers != nil {
s.MySubscribers = mySubscribers
}
}
}
func WithOnBehalfOfContentOwnerChannel(onBehalfOfContentOwnerChannel string) Option {
return func(s *Subscription) {
s.OnBehalfOfContentOwnerChannel = onBehalfOfContentOwnerChannel
}
}
func WithOrder(order string) Option {
return func(s *Subscription) {
s.Order = order
}
}
func WithTitle(title string) Option {
return func(s *Subscription) {
s.Title = title
}
}
var (
WithParts = common.WithParts[*Subscription]
WithOutput = common.WithOutput[*Subscription]
WithService = common.WithService[*Subscription]
WithIds = common.WithIds[*Subscription]
WithMaxResults = common.WithMaxResults[*Subscription]
WithChannelId = common.WithChannelId[*Subscription]
WithOnBehalfOfContentOwner = common.WithOnBehalfOfContentOwner[*Subscription]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package superChatEvent
import (
"errors"
"io"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetSuperChatEvent = errors.New("failed to get super chat event")
)
type SuperChatEvent struct {
*common.Fields
}
type ISuperChatEvent[T any] interface {
Get() ([]*T, error)
List(io.Writer) error
}
type Option func(*SuperChatEvent)
func NewSuperChatEvent(opts ...Option) ISuperChatEvent[youtube.SuperChatEvent] {
s := &SuperChatEvent{Fields: &common.Fields{}}
for _, opt := range opts {
opt(s)
}
return s
}
func (s *SuperChatEvent) Get() ([]*youtube.SuperChatEvent, error) {
if err := s.EnsureService(); err != nil {
return nil, err
}
call := s.Service.SuperChatEvents.List(s.Parts)
if s.Hl != "" {
call = call.Hl(s.Hl)
}
return common.Paginate(
s.Fields, call,
func(r *youtube.SuperChatEventListResponse) ([]*youtube.SuperChatEvent, string) {
return r.Items, r.NextPageToken
}, errGetSuperChatEvent,
)
}
func (s *SuperChatEvent) List(writer io.Writer) error {
events, err := s.Get()
if err != nil && events == nil {
return err
}
common.PrintList(
s.Output, events, writer, table.Row{"ID", "Amount", "Comment", "Supporter"},
func(e *youtube.SuperChatEvent) table.Row {
return table.Row{e.Id, e.Snippet.DisplayString, e.Snippet.CommentText, e.Snippet.SupporterDetails.DisplayName}
},
)
return err
}
var (
WithHl = common.WithHl[*SuperChatEvent]
WithMaxResults = common.WithMaxResults[*SuperChatEvent]
WithParts = common.WithParts[*SuperChatEvent]
WithOutput = common.WithOutput[*SuperChatEvent]
WithService = common.WithService[*SuperChatEvent]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package thumbnail
import (
"errors"
"io"
"github.com/eat-pray-ai/yutu/pkg"
"github.com/eat-pray-ai/yutu/pkg/common"
)
var (
errSetThumbnail = errors.New("failed to set thumbnail")
)
type Thumbnail struct {
*common.Fields
File string `yaml:"file" json:"file,omitempty"`
VideoId string `yaml:"video_id" json:"video_id,omitempty"`
}
type IThumbnail interface {
Set(io.Writer) error
}
type Option func(*Thumbnail)
func NewThumbnail(opts ...Option) IThumbnail {
t := &Thumbnail{Fields: &common.Fields{}}
for _, opt := range opts {
opt(t)
}
return t
}
func (t *Thumbnail) Set(writer io.Writer) error {
if err := t.EnsureService(); err != nil {
return err
}
file, err := pkg.Root.Open(t.File)
if err != nil {
return errors.Join(errSetThumbnail, err)
}
call := t.Service.Thumbnails.Set(t.VideoId).Media(file)
res, err := call.Do()
if err != nil {
return errors.Join(errSetThumbnail, err)
}
common.PrintResult(
t.Output, res, writer, "Thumbnail set for video %s", t.VideoId,
)
return nil
}
func WithVideoId(videoId string) Option {
return func(t *Thumbnail) {
t.VideoId = videoId
}
}
func WithFile(file string) Option {
return func(t *Thumbnail) {
t.File = file
}
}
var (
WithOutput = common.WithOutput[*Thumbnail]
WithService = common.WithService[*Thumbnail]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package utils
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"gopkg.in/yaml.v3"
)
func PrintJSON(data any, writer io.Writer) {
marshalled, _ := json.MarshalIndent(data, "", " ")
_, _ = fmt.Fprintln(writer, string(marshalled))
}
func PrintYAML(data any, writer io.Writer) {
marshalled, _ := yaml.Marshal(data)
_, _ = fmt.Fprintln(writer, string(marshalled))
}
func OpenURL(url string) error {
var err error
switch runtime.GOOS {
case "windows":
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
case "linux":
err = exec.Command("xdg-open", url).Start()
case "darwin":
err = exec.Command("open", url).Start()
default:
err = fmt.Errorf("cannot open URL %s on this platform", url)
}
return err
}
func RandomStage() string {
b := make([]byte, 128)
_, _ = rand.Read(b)
state := base64.URLEncoding.EncodeToString(b)
return state
}
func GetFileName(file string) string {
base := filepath.Base(file)
fileName := base[:len(base)-len(filepath.Ext(base))]
return fileName
}
func IsJson(s string) bool {
var js json.RawMessage
return json.Unmarshal([]byte(s), &js) == nil
}
func StrToBoolPtr(b *string) *bool {
if b == nil || *b == "" || strings.ToLower(strings.TrimSpace(*b)) == "null" {
return nil
}
return new(*b == "true")
}
func BoolToStrPtr(b *bool) *string {
if b == nil {
return new("")
}
return new(strconv.FormatBool(*b))
}
func ResetBool(m map[string]**bool, flagSet *pflag.FlagSet) {
for k := range m {
flag := flagSet.Lookup(k)
if flag != nil && !flag.Changed {
*m[k] = nil
}
}
}
func ExtractHl(uri string) string {
pattern := `i18n://(?:language|region)/([^/]+)`
matches := regexp.MustCompile(pattern).FindStringSubmatch(uri)
if len(matches) > 1 {
return matches[1]
}
return ""
}
func HandleCmdError(err error, cmd *cobra.Command) {
if err != nil {
_ = cmd.Help()
cmd.PrintErrf("Error: %v\n", err)
}
}
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package video
import (
"errors"
"fmt"
"io"
"os"
"slices"
"github.com/eat-pray-ai/yutu/pkg"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/eat-pray-ai/yutu/pkg/playlistItem"
"github.com/eat-pray-ai/yutu/pkg/thumbnail"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/eat-pray-ai/yutu/pkg/utils"
"google.golang.org/api/youtube/v3"
)
var (
errGetVideo = errors.New("failed to get video")
errInsertVideo = errors.New("failed to insert video")
errUpdateVideo = errors.New("failed to update video")
errRating = errors.New("failed to rate video")
errGetRating = errors.New("failed to get rating")
errDeleteVideo = errors.New("failed to delete video")
errReportAbuse = errors.New("failed to report abuse")
)
type Video struct {
*common.Fields
AutoLevels *bool `yaml:"auto_levels" json:"auto_levels,omitempty"`
File string `yaml:"file" json:"file,omitempty"`
Title string `yaml:"title" json:"title,omitempty"`
Description string `yaml:"description" json:"description,omitempty"`
Tags []string `yaml:"tags" json:"tags,omitempty"`
Language string `yaml:"language" json:"language,omitempty"`
Locale string `yaml:"locale" json:"locale,omitempty"`
License string `yaml:"license" json:"license,omitempty"`
Thumbnail string `yaml:"thumbnail" json:"thumbnail,omitempty"`
Rating string `yaml:"rating" json:"rating,omitempty"`
Chart string `yaml:"chart" json:"chart,omitempty"`
Comments string `yaml:"comments" json:"comments,omitempty"`
PlaylistId string `yaml:"playlist_id" json:"playlist_id,omitempty"`
CategoryId string `yaml:"category_id" json:"category_id,omitempty"`
Privacy string `yaml:"privacy" json:"privacy,omitempty"`
ForKids *bool `yaml:"for_kids" json:"for_kids,omitempty"`
Embeddable *bool `yaml:"embeddable" json:"embeddable,omitempty"`
PublishAt string `yaml:"publish_at" json:"publish_at,omitempty"`
RegionCode string `yaml:"region_code" json:"region_code,omitempty"`
ReasonId string `yaml:"reason_id" json:"reason_id,omitempty"`
Stabilize *bool `yaml:"stabilize" json:"stabilize,omitempty"`
MaxHeight int64 `yaml:"max_height" json:"max_height,omitempty"`
MaxWidth int64 `yaml:"max_width" json:"max_width,omitempty"`
SecondaryReasonId string `yaml:"secondary_reason_id" json:"secondary_reason_id,omitempty"`
NotifySubscribers *bool `yaml:"notify_subscribers" json:"notify_subscribers,omitempty"`
PublicStatsViewable *bool `yaml:"public_stats_viewable" json:"public_stats_viewable,omitempty"`
OnBehalfOfContentOwnerChannel string `yaml:"on_behalf_of_content_owner_channel" json:"on_behalf_of_content_owner_channel,omitempty"`
}
type IVideo[T any] interface {
List(io.Writer) error
Insert(io.Writer) error
Update(io.Writer) error
Rate(io.Writer) error
GetRating(io.Writer) error
Delete(io.Writer) error
ReportAbuse(io.Writer) error
Get() ([]*T, error)
}
type Option func(*Video)
func NewVideo(opts ...Option) IVideo[youtube.Video] {
v := &Video{Fields: &common.Fields{}}
for _, opt := range opts {
opt(v)
}
return v
}
func (v *Video) Get() ([]*youtube.Video, error) {
if err := v.EnsureService(); err != nil {
return nil, err
}
call := v.Service.Videos.List(v.Parts)
if len(v.Ids) > 0 {
call = call.Id(v.Ids...)
}
if v.Chart != "" {
call = call.Chart(v.Chart)
}
if v.Hl != "" {
call = call.Hl(v.Hl)
}
if v.Locale != "" {
call = call.Locale(v.Locale)
}
if v.CategoryId != "" {
call = call.VideoCategoryId(v.CategoryId)
}
if v.Rating != "" {
call = call.MyRating(v.Rating)
}
if v.RegionCode != "" {
call = call.RegionCode(v.RegionCode)
}
if v.MaxHeight != 0 {
call = call.MaxHeight(v.MaxHeight)
}
if v.MaxWidth != 0 {
call = call.MaxWidth(v.MaxWidth)
}
if v.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(v.OnBehalfOfContentOwner)
}
return common.Paginate(
v.Fields, call,
func(r *youtube.VideoListResponse) ([]*youtube.Video, string) {
return r.Items, r.NextPageToken
}, errGetVideo,
)
}
func (v *Video) List(writer io.Writer) error {
videos, err := v.Get()
if err != nil && videos == nil {
return err
}
common.PrintList(
v.Output, videos, writer, table.Row{"ID", "Title", "Channel ID", "Views"},
func(video *youtube.Video) table.Row {
title := ""
channelId := ""
var views uint64
if video.Snippet != nil {
title = video.Snippet.Title
channelId = video.Snippet.ChannelId
}
if video.Statistics != nil {
views = video.Statistics.ViewCount
}
return table.Row{video.Id, title, channelId, views}
},
)
return err
}
func (v *Video) Insert(writer io.Writer) error {
if err := v.EnsureService(); err != nil {
return err
}
file, err := pkg.Root.Open(v.File)
if err != nil {
return errors.Join(errInsertVideo, err)
}
defer func(file *os.File) {
_ = file.Close()
}(file)
if !slices.Contains(v.Tags, "yutu🐰") {
v.Tags = append(v.Tags, "yutu🐰")
}
if v.Title == "" {
v.Title = utils.GetFileName(v.File)
}
video := &youtube.Video{
Snippet: &youtube.VideoSnippet{
Title: v.Title,
Description: v.Description,
Tags: v.Tags,
CategoryId: v.CategoryId,
ChannelId: v.ChannelId,
DefaultLanguage: v.Language,
DefaultAudioLanguage: v.Language,
},
Status: &youtube.VideoStatus{
License: v.License,
PublishAt: v.PublishAt,
PrivacyStatus: v.Privacy,
ForceSendFields: []string{"SelfDeclaredMadeForKids"},
},
}
if v.Embeddable != nil {
video.Status.Embeddable = *v.Embeddable
}
if v.ForKids != nil {
video.Status.SelfDeclaredMadeForKids = *v.ForKids
}
if v.PublicStatsViewable != nil {
video.Status.PublicStatsViewable = *v.PublicStatsViewable
}
call := v.Service.Videos.Insert([]string{"snippet,status"}, video)
if v.AutoLevels != nil {
call = call.AutoLevels(*v.AutoLevels)
}
if v.NotifySubscribers != nil {
call = call.NotifySubscribers(*v.NotifySubscribers)
}
if v.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(v.OnBehalfOfContentOwner)
}
if v.OnBehalfOfContentOwnerChannel != "" {
call = call.OnBehalfOfContentOwnerChannel(v.OnBehalfOfContentOwnerChannel)
}
if v.Stabilize != nil {
call = call.Stabilize(*v.Stabilize)
}
res, err := call.Media(file).Do()
if err != nil {
return errors.Join(errInsertVideo, err)
}
if v.Thumbnail != "" {
t := thumbnail.NewThumbnail(
thumbnail.WithVideoId(res.Id),
thumbnail.WithFile(v.Thumbnail),
thumbnail.WithService(v.Service),
thumbnail.WithOutput("silent"),
)
_ = t.Set(nil)
}
if v.PlaylistId != "" {
pi := playlistItem.NewPlaylistItem(
playlistItem.WithTitle(res.Snippet.Title),
playlistItem.WithDescription(res.Snippet.Description),
playlistItem.WithKind("video"),
playlistItem.WithKVideoId(res.Id),
playlistItem.WithPlaylistId(v.PlaylistId),
playlistItem.WithChannelId(res.Snippet.ChannelId),
playlistItem.WithPrivacy(res.Status.PrivacyStatus),
playlistItem.WithService(v.Service),
playlistItem.WithOutput("silent"),
)
_ = pi.Insert(writer)
}
common.PrintResult(v.Output, res, writer, "Video inserted: %s\n", res.Id)
return nil
}
func (v *Video) Update(writer io.Writer) error {
if err := v.EnsureService(); err != nil {
return err
}
v.Parts = []string{"id", "snippet", "status"}
videos, err := v.Get()
if err != nil {
return errors.Join(errUpdateVideo, err)
}
if len(videos) == 0 {
return errGetVideo
}
video := videos[0]
if v.Title != "" {
video.Snippet.Title = v.Title
}
if v.Description != "" {
video.Snippet.Description = v.Description
}
if v.Tags != nil {
if !slices.Contains(v.Tags, "yutu🐰") {
v.Tags = append(v.Tags, "yutu🐰")
}
video.Snippet.Tags = v.Tags
}
if v.Language != "" {
video.Snippet.DefaultLanguage = v.Language
video.Snippet.DefaultAudioLanguage = v.Language
}
if v.License != "" {
video.Status.License = v.License
}
if v.CategoryId != "" {
video.Snippet.CategoryId = v.CategoryId
}
if v.Privacy != "" {
video.Status.PrivacyStatus = v.Privacy
}
if v.Embeddable != nil {
video.Status.Embeddable = *v.Embeddable
}
call := v.Service.Videos.Update([]string{"snippet,status"}, video)
if v.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(v.OnBehalfOfContentOwner)
}
res, err := call.Do()
if err != nil {
return errors.Join(errUpdateVideo, err)
}
if v.Thumbnail != "" {
t := thumbnail.NewThumbnail(
thumbnail.WithVideoId(res.Id),
thumbnail.WithFile(v.Thumbnail),
thumbnail.WithService(v.Service),
thumbnail.WithOutput("silent"),
)
_ = t.Set(nil)
}
if v.PlaylistId != "" {
pi := playlistItem.NewPlaylistItem(
playlistItem.WithTitle(res.Snippet.Title),
playlistItem.WithDescription(res.Snippet.Description),
playlistItem.WithKind("video"),
playlistItem.WithKVideoId(res.Id),
playlistItem.WithPlaylistId(v.PlaylistId),
playlistItem.WithChannelId(res.Snippet.ChannelId),
playlistItem.WithPrivacy(res.Status.PrivacyStatus),
playlistItem.WithService(v.Service),
playlistItem.WithOutput("silent"),
)
_ = pi.Insert(writer)
}
common.PrintResult(v.Output, res, writer, "Video updated: %s\n", res.Id)
return nil
}
func (v *Video) Rate(writer io.Writer) error {
if err := v.EnsureService(); err != nil {
return err
}
for _, id := range v.Ids {
call := v.Service.Videos.Rate(id, v.Rating)
err := call.Do()
if err != nil {
return errors.Join(errRating, err)
}
_, _ = fmt.Fprintf(writer, "Video %s rated %s\n", id, v.Rating)
}
return nil
}
func (v *Video) GetRating(writer io.Writer) error {
if err := v.EnsureService(); err != nil {
return err
}
call := v.Service.Videos.GetRating(v.Ids)
if v.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(v.OnBehalfOfContentOwner)
}
res, err := call.Do()
if err != nil {
return errors.Join(errGetRating, err)
}
switch v.Output {
case "json":
utils.PrintJSON(res.Items, writer)
case "yaml":
utils.PrintYAML(res.Items, writer)
default:
tb := table.NewWriter()
defer tb.Render()
tb.SetOutputMirror(writer)
tb.SetStyle(pkg.TableStyle)
tb.AppendHeader(table.Row{"ID", "Rating"})
for _, item := range res.Items {
tb.AppendRow(table.Row{item.VideoId, item.Rating})
}
}
return nil
}
func (v *Video) Delete(writer io.Writer) error {
if err := v.EnsureService(); err != nil {
return err
}
for _, id := range v.Ids {
call := v.Service.Videos.Delete(id)
if v.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(v.OnBehalfOfContentOwner)
}
err := call.Do()
if err != nil {
return errors.Join(errDeleteVideo, err)
}
_, _ = fmt.Fprintf(writer, "Video %s deleted", id)
}
return nil
}
func (v *Video) ReportAbuse(writer io.Writer) error {
if err := v.EnsureService(); err != nil {
return err
}
for _, id := range v.Ids {
videoAbuseReport := &youtube.VideoAbuseReport{
Comments: v.Comments,
Language: v.Language,
ReasonId: v.ReasonId,
SecondaryReasonId: v.SecondaryReasonId,
VideoId: id,
}
call := v.Service.Videos.ReportAbuse(videoAbuseReport)
if v.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(v.OnBehalfOfContentOwner)
}
err := call.Do()
if err != nil {
return errors.Join(errReportAbuse, err)
}
_, _ = fmt.Fprintf(writer, "Video %s reported for abuse", id)
}
return nil
}
func WithAutoLevels(autoLevels *bool) Option {
return func(v *Video) {
if autoLevels != nil {
v.AutoLevels = autoLevels
}
}
}
func WithFile(file string) Option {
return func(v *Video) {
v.File = file
}
}
func WithTitle(title string) Option {
return func(v *Video) {
v.Title = title
}
}
func WithDescription(description string) Option {
return func(v *Video) {
v.Description = description
}
}
func WithTags(tags []string) Option {
return func(v *Video) {
v.Tags = tags
}
}
func WithLanguage(language string) Option {
return func(v *Video) {
v.Language = language
}
}
func WithLocale(locale string) Option {
return func(v *Video) {
v.Locale = locale
}
}
func WithLicense(license string) Option {
return func(v *Video) {
v.License = license
}
}
func WithThumbnail(thumbnail string) Option {
return func(v *Video) {
v.Thumbnail = thumbnail
}
}
func WithRating(rating string) Option {
return func(v *Video) {
v.Rating = rating
}
}
func WithChart(chart string) Option {
return func(v *Video) {
v.Chart = chart
}
}
func WithForKids(forKids *bool) Option {
return func(v *Video) {
if forKids != nil {
v.ForKids = forKids
}
}
}
func WithEmbeddable(embeddable *bool) Option {
return func(v *Video) {
if embeddable != nil {
v.Embeddable = embeddable
}
}
}
func WithCategory(categoryId string) Option {
return func(v *Video) {
v.CategoryId = categoryId
}
}
func WithPrivacy(privacy string) Option {
return func(v *Video) {
v.Privacy = privacy
}
}
func WithPlaylistId(playlistId string) Option {
return func(v *Video) {
v.PlaylistId = playlistId
}
}
func WithPublicStatsViewable(publicStatsViewable *bool) Option {
return func(v *Video) {
if publicStatsViewable != nil {
v.PublicStatsViewable = publicStatsViewable
}
}
}
func WithPublishAt(publishAt string) Option {
return func(v *Video) {
v.PublishAt = publishAt
}
}
func WithRegionCode(regionCode string) Option {
return func(v *Video) {
v.RegionCode = regionCode
}
}
func WithStabilize(stabilize *bool) Option {
return func(v *Video) {
if stabilize != nil {
v.Stabilize = stabilize
}
}
}
func WithMaxHeight(maxHeight int64) Option {
return func(v *Video) {
v.MaxHeight = maxHeight
}
}
func WithMaxWidth(maxWidth int64) Option {
return func(v *Video) {
v.MaxWidth = maxWidth
}
}
func WithNotifySubscribers(notifySubscribers *bool) Option {
return func(v *Video) {
if notifySubscribers != nil {
v.NotifySubscribers = notifySubscribers
}
}
}
func WithOnBehalfOfContentOwnerChannel(onBehalfOfContentOwnerChannel string) Option {
return func(v *Video) {
v.OnBehalfOfContentOwnerChannel = onBehalfOfContentOwnerChannel
}
}
func WithComments(comments string) Option {
return func(v *Video) {
v.Comments = comments
}
}
func WithReasonId(reasonId string) Option {
return func(v *Video) {
v.ReasonId = reasonId
}
}
func WithSecondaryReasonId(secondaryReasonId string) Option {
return func(v *Video) {
v.SecondaryReasonId = secondaryReasonId
}
}
var (
WithParts = common.WithParts[*Video]
WithOutput = common.WithOutput[*Video]
WithService = common.WithService[*Video]
WithIds = common.WithIds[*Video]
WithMaxResults = common.WithMaxResults[*Video]
WithHl = common.WithHl[*Video]
WithChannelId = common.WithChannelId[*Video]
WithOnBehalfOfContentOwner = common.WithOnBehalfOfContentOwner[*Video]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package videoAbuseReportReason
import (
"errors"
"io"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetVideoAbuseReportReason = errors.New("failed to get video abuse report reason")
)
type VideoAbuseReportReason struct {
*common.Fields
}
type IVideoAbuseReportReason[T any] interface {
Get() ([]*T, error)
List(io.Writer) error
}
type Option func(*VideoAbuseReportReason)
func NewVideoAbuseReportReason(opt ...Option) IVideoAbuseReportReason[youtube.VideoAbuseReportReason] {
va := &VideoAbuseReportReason{Fields: &common.Fields{}}
for _, o := range opt {
o(va)
}
return va
}
func (va *VideoAbuseReportReason) Get() (
[]*youtube.VideoAbuseReportReason, error,
) {
if err := va.EnsureService(); err != nil {
return nil, err
}
call := va.Service.VideoAbuseReportReasons.List(va.Parts)
if va.Hl != "" {
call = call.Hl(va.Hl)
}
res, err := call.Do()
if err != nil {
return nil, errors.Join(errGetVideoAbuseReportReason, err)
}
return res.Items, nil
}
func (va *VideoAbuseReportReason) List(writer io.Writer) error {
reasons, err := va.Get()
if err != nil {
return err
}
common.PrintList(
va.Output, reasons, writer, table.Row{"ID", "Label"},
func(r *youtube.VideoAbuseReportReason) table.Row {
return table.Row{r.Id, r.Snippet.Label}
},
)
return nil
}
var (
WithHL = common.WithHl[*VideoAbuseReportReason]
WithParts = common.WithParts[*VideoAbuseReportReason]
WithOutput = common.WithOutput[*VideoAbuseReportReason]
WithService = common.WithService[*VideoAbuseReportReason]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package videoCategory
import (
"errors"
"io"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetVideoCategory = errors.New("failed to get video categoryId")
)
type VideoCategory struct {
*common.Fields
RegionCode string `yaml:"region_code" json:"region_code,omitempty"`
}
type IVideoCategory[T any] interface {
Get() ([]*T, error)
List(io.Writer) error
}
type Option func(*VideoCategory)
func NewVideoCategory(opt ...Option) IVideoCategory[youtube.VideoCategory] {
vc := &VideoCategory{Fields: &common.Fields{}}
for _, o := range opt {
o(vc)
}
return vc
}
func (vc *VideoCategory) Get() ([]*youtube.VideoCategory, error) {
if err := vc.EnsureService(); err != nil {
return nil, err
}
call := vc.Service.VideoCategories.List(vc.Parts)
if len(vc.Ids) > 0 {
call = call.Id(vc.Ids...)
}
if vc.Hl != "" {
call = call.Hl(vc.Hl)
}
if vc.RegionCode != "" {
call = call.RegionCode(vc.RegionCode)
}
res, err := call.Do()
if err != nil {
return nil, errors.Join(errGetVideoCategory, err)
}
return res.Items, nil
}
func (vc *VideoCategory) List(writer io.Writer) error {
categories, err := vc.Get()
if err != nil {
return err
}
common.PrintList(
vc.Output, categories, writer, table.Row{"ID", "Title", "Assignable"},
func(c *youtube.VideoCategory) table.Row {
return table.Row{c.Id, c.Snippet.Title, c.Snippet.Assignable}
},
)
return nil
}
func WithRegionCode(regionCode string) Option {
return func(vc *VideoCategory) {
vc.RegionCode = regionCode
}
}
var (
WithIds = common.WithIds[*VideoCategory]
WithHl = common.WithHl[*VideoCategory]
WithParts = common.WithParts[*VideoCategory]
WithOutput = common.WithOutput[*VideoCategory]
WithService = common.WithService[*VideoCategory]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package watermark
import (
"errors"
"fmt"
"io"
"os"
"github.com/eat-pray-ai/yutu/pkg"
"github.com/eat-pray-ai/yutu/pkg/common"
"google.golang.org/api/youtube/v3"
)
var (
errSetWatermark = errors.New("failed to set watermark")
errUnsetWatermark = errors.New("failed to unset watermark")
)
type Watermark struct {
*common.Fields
File string `yaml:"file" json:"file,omitempty"`
InVideoPosition string `yaml:"in_video_position" json:"in_video_position,omitempty"`
DurationMs uint64 `yaml:"duration_ms" json:"duration_ms,omitempty"`
OffsetMs uint64 `yaml:"offset_ms" json:"offset_ms,omitempty"`
OffsetType string `yaml:"offset_type" json:"offset_type,omitempty"`
}
type IWatermark interface {
Set(io.Writer) error
Unset(io.Writer) error
}
type Option func(*Watermark)
func NewWatermark(opts ...Option) IWatermark {
w := &Watermark{Fields: &common.Fields{}}
for _, opt := range opts {
opt(w)
}
return w
}
func (w *Watermark) Set(writer io.Writer) error {
if err := w.EnsureService(); err != nil {
return err
}
file, err := pkg.Root.Open(w.File)
if err != nil {
return errors.Join(errSetWatermark, err)
}
defer func(file *os.File) {
_ = file.Close()
}(file)
inVideoBranding := &youtube.InvideoBranding{
Position: &youtube.InvideoPosition{},
Timing: &youtube.InvideoTiming{},
}
if w.InVideoPosition != "" {
inVideoBranding.Position.Type = "corner"
inVideoBranding.Position.CornerPosition = w.InVideoPosition
}
if w.DurationMs != 0 {
inVideoBranding.Timing.DurationMs = w.DurationMs
}
if w.OffsetMs != 0 {
inVideoBranding.Timing.OffsetMs = w.OffsetMs
}
if w.OffsetType != "" {
inVideoBranding.Timing.Type = w.OffsetType
}
call := w.Service.Watermarks.Set(w.ChannelId, inVideoBranding).Media(file)
if w.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(w.OnBehalfOfContentOwner)
}
err = call.Do()
if err != nil {
return errors.Join(errSetWatermark, err)
}
_, _ = fmt.Fprintf(writer, "Watermark set for channel %s\n", w.ChannelId)
return nil
}
func (w *Watermark) Unset(writer io.Writer) error {
if err := w.EnsureService(); err != nil {
return err
}
call := w.Service.Watermarks.Unset(w.ChannelId)
if w.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(w.OnBehalfOfContentOwner)
}
err := call.Do()
if err != nil {
return errors.Join(errUnsetWatermark, err)
}
_, _ = fmt.Fprintf(writer, "Watermark unset for channel %s\n", w.ChannelId)
return nil
}
func WithFile(file string) Option {
return func(w *Watermark) {
w.File = file
}
}
func WithInVideoPosition(inVideoPosition string) Option {
return func(w *Watermark) {
w.InVideoPosition = inVideoPosition
}
}
func WithDurationMs(durationMs uint64) Option {
return func(w *Watermark) {
w.DurationMs = durationMs
}
}
func WithOffsetMs(offsetMs uint64) Option {
return func(w *Watermark) {
w.OffsetMs = offsetMs
}
}
func WithOffsetType(offsetType string) Option {
return func(w *Watermark) {
w.OffsetType = offsetType
}
}
var (
WithChannelId = common.WithChannelId[*Watermark]
WithService = common.WithService[*Watermark]
WithOnBehalfOfContentOwner = common.WithOnBehalfOfContentOwner[*Watermark]
)