// Copyright 2025 eat-pray-ai & OpenWaygate // SPDX-License-Identifier: Apache-2.0 package activity import ( "errors" "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/youtube/v3" ) var ( service *youtube.Service errGetActivity = errors.New("failed to get activity") ) type activity struct { ChannelId string `yaml:"channel_id" json:"channel_id"` Home *bool `yaml:"home" json:"home"` MaxResults int64 `yaml:"max_results" json:"max_results"` Mine *bool `yaml:"mine" json:"mine"` PublishedAfter string `yaml:"published_after" json:"published_after"` PublishedBefore string `yaml:"published_before" json:"published_before"` RegionCode string `yaml:"region_code" json:"region_code"` } type Activity[T any] interface { List([]string, string, string, io.Writer) error Get([]string) ([]*T, error) } type Option func(*activity) func NewActivity(opts ...Option) Activity[youtube.Activity] { a := &activity{} for _, opt := range opts { opt(a) } return a } func (a *activity) Get(parts []string) ([]*youtube.Activity, error) { call := service.Activities.List(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) } var items []*youtube.Activity pageToken := "" for a.MaxResults > 0 { call = call.MaxResults(min(a.MaxResults, pkg.PerPage)) a.MaxResults -= pkg.PerPage if pageToken != "" { call = call.PageToken(pageToken) } res, err := call.Do() if err != nil { return items, errors.Join(errGetActivity, err) } items = append(items, res.Items...) pageToken = res.NextPageToken if pageToken == "" || len(res.Items) == 0 { break } } return items, nil } func (a *activity) List( parts []string, output string, jpath string, writer io.Writer, ) error { activities, err := a.Get(parts) if err != nil && activities == nil { return err } switch output { case "json": utils.PrintJSON(activities, jpath, writer) case "yaml": utils.PrintYAML(activities, jpath, writer) case "table": tb := table.NewWriter() defer tb.Render() tb.SetOutputMirror(writer) tb.SetStyle(table.StyleLight) tb.SetAutoIndex(true) tb.AppendHeader(table.Row{"ID", "Title", "Type", "Time"}) for _, activity := range activities { tb.AppendRow( table.Row{ activity.Id, activity.Snippet.Title, activity.Snippet.Type, activity.Snippet.PublishedAt, }, ) } } return err } func WithChannelId(channelId string) Option { return func(a *activity) { a.ChannelId = channelId } } func WithHome(home *bool) Option { return func(a *activity) { if home != nil { a.Home = home } } } func WithMaxResults(maxResults int64) Option { return func(a *activity) { if maxResults < 0 { maxResults = 1 } else if maxResults == 0 { maxResults = math.MaxInt64 } a.MaxResults = maxResults } } 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 } } func WithService(svc *youtube.Service) Option { return func(_ *activity) { if svc == nil { svc = auth.NewY2BService( auth.WithCredential("", pkg.Root.FS()), auth.WithCacheToken("", pkg.Root.FS()), ).GetService() } service = svc } }
// 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" 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 'localhost:8216/?state=DONOT-COPY&code=COPY-THIS&scope=DONOT-COPY' ONLY 'COPY-THIS' part is the code you need to enter on command line. ` ) var ( state = utils.RandomStage() scope = []string{ youtube.YoutubeScope, youtube.YoutubeForceSslScope, youtube.YoutubeChannelMembershipsCreatorScope, } ) func (s *svc) GetService() *youtube.Service { client := s.refreshClient() service, err := youtube.NewService(s.ctx, option.WithHTTPClient(client)) if err != nil { slog.Error(createSvcFailed, "error", err) os.Exit(1) } s.service = service return s.service } func (s *svc) refreshClient() (client *http.Client) { config := s.getConfig() authedToken := &oauth2.Token{} err := json.Unmarshal([]byte(s.CacheToken), authedToken) if err != nil { client, authedToken = s.newClient(config) if s.tokenFile != "" { s.saveToken(authedToken) } return client } if !authedToken.Valid() { tokenSource := config.TokenSource(s.ctx, authedToken) authedToken, err = tokenSource.Token() if err != nil && s.tokenFile != "" { client, authedToken = s.newClient(config) s.saveToken(authedToken) return client } else if err != nil { slog.Error(refreshTokenFailed, "error", err) os.Exit(1) } if authedToken != nil && s.tokenFile != "" { s.saveToken(authedToken) } return config.Client(s.ctx, authedToken) } return config.Client(s.ctx, authedToken) } func (s *svc) newClient(config *oauth2.Config) ( client *http.Client, token *oauth2.Token, ) { authURL := config.AuthCodeURL(state, oauth2.AccessTypeOffline) token = s.getTokenFromWeb(config, authURL) client = config.Client(s.ctx, token) return } func (s *svc) getConfig() *oauth2.Config { config, err := google.ConfigFromJSON([]byte(s.Credential), scope...) if err != nil { slog.Error(parseSecretFailed, "error", err) os.Exit(1) } return config } func (s *svc) startWebServer(redirectURL string) chan string { u, err := url.Parse(redirectURL) if err != nil { slog.Error(parseUrlFailed, "url", redirectURL, "error", err) os.Exit(1) } listener, err := net.Listen("tcp", u.Host) if err != nil { slog.Error(listenFailed, "host", u.Host, "error", err) os.Exit(1) } codeCh := make(chan string) go func() { _ = http.Serve( listener, http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { return } s := r.FormValue("state") if s != state { slog.Error( stateMatchFailed, "actual", s, "expected", state, ) os.Exit(1) } code := r.FormValue("code") codeCh <- code _ = listener.Close() w.Header().Set("Content-Type", "text/plain") _, _ = fmt.Fprintf( w, "Received code: %s\r\nYou can now safely close this window.", code, ) }, ), ) }() return codeCh } func (s *svc) getCodeFromPrompt(authURL string) (code string) { fmt.Printf(openBrowserHint, authURL) fmt.Print(manualInputHint) _, err := fmt.Scan(&code) if err != nil { slog.Error(readPromptFailed, "error", err) os.Exit(1) } if strings.HasPrefix(code, "4%2F") { code = strings.Replace(code, "4%2F", "4/", 1) } return code } func (s *svc) getTokenFromWeb( config *oauth2.Config, authURL string, ) *oauth2.Token { codeCh := s.startWebServer(config.RedirectURL) var code string if err := utils.OpenURL(authURL); err == nil { fmt.Printf(browserOpenedHint, authURL) code = <-codeCh } if code == "" { code = s.getCodeFromPrompt(authURL) } slog.Debug("Authorization code generated", "code", code) token, err := config.Exchange(context.TODO(), code) if err != nil { slog.Error(exchangeFailed, "error", err) os.Exit(1) } return token } func (s *svc) saveToken(token *oauth2.Token) { dir := filepath.Dir(s.tokenFile) if err := pkg.Root.MkdirAll(dir, 0755); err != nil { slog.Error(cacheTokenFailed, "dir", dir, "error", err) os.Exit(1) } 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) os.Exit(1) } defer func() { _ = f.Close() }() err = json.NewEncoder(f).Encode(token) if err != nil { slog.Error(cacheTokenFailed, "error", err) os.Exit(1) } slog.Info("Token cached to file", "file", s.tokenFile) }
// Copyright 2025 eat-pray-ai & OpenWaygate // SPDX-License-Identifier: Apache-2.0 package auth import ( "context" "encoding/base64" "io/fs" "log/slog" "net/http" "os" "path/filepath" "strings" "github.com/eat-pray-ai/yutu/pkg/utils" "golang.org/x/oauth2" "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 service *youtube.Service ctx context.Context } type Svc interface { GetService() *youtube.Service refreshClient() *http.Client newClient(*oauth2.Config) (*http.Client, *oauth2.Token) getConfig() *oauth2.Config startWebServer(string) chan string getTokenFromWeb(*oauth2.Config, string) *oauth2.Token getCodeFromPrompt(string) string saveToken(*oauth2.Token) } type Option func(*svc) func NewY2BService(opts ...Option) Svc { s := &svc{} s.ctx = context.Background() s.credFile = "client_secret.json" for _, opt := range opts { opt(s) } return s } 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("/", absCred) if _, err := fs.Stat(fsys, relCred); err == nil { s.credFile = absCred credBytes, err := fs.ReadFile(fsys, relCred) if err != nil { slog.Error( readSecretFailed, "hint", authHint, "path", absCred, "error", err, ) os.Exit(1) } 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 { slog.Error(parseSecretFailed, "hint", authHint, "error", err) os.Exit(1) } } } 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("/", absToken) 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, "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/auth" "github.com/eat-pray-ai/yutu/pkg/utils" "github.com/jedib0t/go-pretty/v6/table" "google.golang.org/api/youtube/v3" ) var ( service *youtube.Service 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 { IDs []string `yaml:"ids" json:"ids"` File string `yaml:"file" json:"file"` AudioTrackType string `yaml:"audio_track_type" json:"audio_track_type"` IsAutoSynced *bool `yaml:"is_auto_synced" json:"is_auto_synced"` IsCC *bool `yaml:"is_cc" json:"is_cc"` IsDraft *bool `yaml:"is_draft" json:"is_draft"` IsEasyReader *bool `yaml:"is_easy_reader" json:"is_easy_reader"` IsLarge *bool `yaml:"is_large" json:"is_large"` Language string `yaml:"language" json:"language"` Name string `yaml:"name" json:"name"` TrackKind string `yaml:"track_kind" json:"track_kind"` OnBehalfOf string `yaml:"on_behalf_of" json:"on_behalf_of"` OnBehalfOfContentOwner string `yaml:"on_behalf_of_content_owner" json:"on_behalf_of_content_owner"` VideoId string `yaml:"video_id" json:"video_id"` Tfmt string `yaml:"tfmt" json:"tfmt"` Tlang string `yaml:"tlang" json:"tlang"` } type Caption[T youtube.Caption] interface { Get([]string) ([]*T, error) List([]string, string, string, io.Writer) error Insert(string, string, io.Writer) error Update(string, string, io.Writer) error Delete(io.Writer) error Download(io.Writer) error } type Option func(*caption) func NewCation(opts ...Option) Caption[youtube.Caption] { c := &caption{} for _, opt := range opts { opt(c) } return c } func (c *caption) Get(parts []string) ([]*youtube.Caption, error) { call := service.Captions.List(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( parts []string, output string, jpath string, writer io.Writer, ) error { captions, err := c.Get(parts) if err != nil { return err } switch output { case "json": utils.PrintJSON(captions, jpath, writer) case "yaml": utils.PrintYAML(captions, jpath, writer) case "table": tb := table.NewWriter() tb.SetOutputMirror(writer) tb.SetStyle(table.StyleLight) tb.SetAutoIndex(true) tb.AppendHeader(table.Row{"ID", "Video ID", "Name", "Language"}) defer tb.Render() for _, caption := range captions { tb.AppendRow( table.Row{ caption.Id, caption.Snippet.VideoId, caption.Snippet.Name, caption.Snippet.Language, }, ) } } return nil } func (c *caption) Insert( output string, jpath string, writer io.Writer, ) error { 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, IsCC: *c.IsCC, IsDraft: *c.IsDraft, IsEasyReader: *c.IsEasyReader, IsLarge: *c.IsLarge, Language: c.Language, Name: c.Name, TrackKind: c.TrackKind, VideoId: c.VideoId, }, } call := 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) } switch output { case "json": utils.PrintJSON(res, jpath, writer) case "yaml": utils.PrintYAML(res, jpath, writer) case "silent": default: _, _ = fmt.Fprintf(writer, "Caption inserted: %s\n", res.Id) } return nil } func (c *caption) Update( output string, jpath string, writer io.Writer, ) error { captions, err := c.Get([]string{"snippet"}) 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 := 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) } switch output { case "json": utils.PrintJSON(res, jpath, writer) case "yaml": utils.PrintYAML(res, jpath, writer) case "silent": default: _, _ = fmt.Fprintf(writer, "Caption updated: %s\n", res.Id) } return nil } func (c *caption) Delete(writer io.Writer) error { for _, id := range c.IDs { call := 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 { call := 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 WithIDs(ids []string) Option { return func(c *caption) { c.IDs = ids } } 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 WithOnBehalfOfContentOwner(onBehalfOfContentOwner string) Option { return func(c *caption) { c.OnBehalfOfContentOwner = onBehalfOfContentOwner } } 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 } } func WithService(svc *youtube.Service) Option { return func(_ *caption) { if svc == nil { svc = auth.NewY2BService( auth.WithCredential("", pkg.Root.FS()), auth.WithCacheToken("", pkg.Root.FS()), ).GetService() } service = svc } }
// Copyright 2025 eat-pray-ai & OpenWaygate // SPDX-License-Identifier: Apache-2.0 package channel 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/youtube/v3" ) var ( service *youtube.Service errGetChannel = errors.New("failed to get channel") errUpdateChannel = errors.New("failed to update channel") ) type channel struct { CategoryId string `yaml:"category_id" json:"category_id"` ForHandle string `yaml:"for_handle" json:"for_handle"` ForUsername string `yaml:"for_username" json:"for_username"` Hl string `yaml:"hl" json:"hl"` IDs []string `yaml:"ids" json:"ids"` ManagedByMe *bool `yaml:"managed_by_me" json:"managed_by_me"` MaxResults int64 `yaml:"max_results" json:"max_results"` Mine *bool `yaml:"mine" json:"mine"` MySubscribers *bool `yaml:"my_subscribers" json:"my_subscribers"` OnBehalfOfContentOwner string `yaml:"on_behalf_of_content_owner" json:"on_behalf_of_content_owner"` Country string `yaml:"country" json:"country"` CustomUrl string `yaml:"custom_url" json:"custom_url"` DefaultLanguage string `yaml:"default_language" json:"default_language"` Description string `yaml:"description" json:"description"` Title string `yaml:"title" json:"title"` } type Channel[T youtube.Channel] interface { List([]string, string, string, io.Writer) error Update(string, string, io.Writer) error Get([]string) ([]*T, error) } type Option func(*channel) func NewChannel(opts ...Option) Channel[youtube.Channel] { c := &channel{} for _, opt := range opts { opt(c) } return c } func (c *channel) Get(parts []string) ([]*youtube.Channel, error) { call := service.Channels.List(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) } var items []*youtube.Channel pageToken := "" for c.MaxResults > 0 { call = call.MaxResults(min(c.MaxResults, pkg.PerPage)) c.MaxResults -= pkg.PerPage if pageToken != "" { call = call.PageToken(pageToken) } res, err := call.Do() if err != nil { return items, errors.Join(errGetChannel, err) } items = append(items, res.Items...) pageToken = res.NextPageToken if pageToken == "" || len(res.Items) == 0 { break } } return items, nil } func (c *channel) List( parts []string, output string, jpath string, writer io.Writer, ) error { channels, err := c.Get(parts) if err != nil && channels == nil { return err } switch output { case "json": utils.PrintJSON(channels, jpath, writer) case "yaml": utils.PrintYAML(channels, jpath, writer) case "table": tb := table.NewWriter() defer tb.Render() tb.SetOutputMirror(writer) tb.SetStyle(table.StyleLight) tb.SetAutoIndex(true) tb.AppendHeader(table.Row{"ID", "Title", "Country"}) for _, channel := range channels { tb.AppendRow( table.Row{channel.Id, channel.Snippet.Title, channel.Snippet.Country}, ) } } return err } func (c *channel) Update(output string, jpath string, writer io.Writer) error { parts := []string{"snippet"} channels, err := c.Get(parts) 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 := service.Channels.Update(parts, cha) res, err := call.Do() if err != nil { return errors.Join(errUpdateChannel, err) } switch output { case "json": utils.PrintJSON(res, jpath, writer) case "yaml": utils.PrintYAML(res, jpath, writer) case "silent": default: _, _ = fmt.Fprintf(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 WithHl(hl string) Option { return func(c *channel) { c.Hl = hl } } func WithIDs(ids []string) Option { return func(c *channel) { c.IDs = ids } } func WithChannelManagedByMe(managedByMe *bool) Option { return func(c *channel) { if managedByMe != nil { c.ManagedByMe = managedByMe } } } func WithMaxResults(maxResults int64) Option { return func(c *channel) { if maxResults < 0 { maxResults = 1 } else if maxResults == 0 { maxResults = math.MaxInt64 } c.MaxResults = maxResults } } 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 WithOnBehalfOfContentOwner(contentOwner string) Option { return func(c *channel) { c.OnBehalfOfContentOwner = contentOwner } } 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 } } func WithService(svc *youtube.Service) Option { return func(_ *channel) { if svc == nil { svc = auth.NewY2BService( auth.WithCredential("", pkg.Root.FS()), auth.WithCacheToken("", pkg.Root.FS()), ).GetService() } service = svc } }
// Copyright 2025 eat-pray-ai & OpenWaygate // SPDX-License-Identifier: Apache-2.0 package channelBanner import ( "errors" "fmt" "io" "os" "github.com/eat-pray-ai/yutu/pkg" "github.com/eat-pray-ai/yutu/pkg/auth" "github.com/eat-pray-ai/yutu/pkg/utils" "google.golang.org/api/youtube/v3" ) var ( service *youtube.Service errInsertChannelBanner = errors.New("failed to insert channelBanner") ) type channelBanner struct { ChannelId string `yaml:"channel_id" json:"channel_id"` File string `yaml:"file" json:"file"` OnBehalfOfContentOwner string `yaml:"on_behalf_of_content_owner" json:"on_behalf_of_content_owner"` OnBehalfOfContentOwnerChannel string `yaml:"on_behalf_of_content_owner_channel" json:"on_behalf_of_content_owner_channel"` } type ChannelBanner interface { Insert(string, string, io.Writer) error } type Option func(banner *channelBanner) func NewChannelBanner(opts ...Option) ChannelBanner { cb := &channelBanner{} for _, opt := range opts { opt(cb) } return cb } func (cb *channelBanner) Insert( output string, jpath string, writer io.Writer, ) error { 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 := 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) } switch output { case "json": utils.PrintJSON(res, jpath, writer) case "yaml": utils.PrintYAML(res, jpath, writer) case "silent": default: _, _ = fmt.Fprintf(writer, "ChannelBanner inserted: %s\n", res.Url) } return nil } func WithChannelId(channelId string) Option { return func(cb *channelBanner) { cb.ChannelId = channelId } } func WithFile(file string) Option { return func(cb *channelBanner) { cb.File = file } } func WithOnBehalfOfContentOwner(onBehalfOfContentOwner string) Option { return func(cb *channelBanner) { cb.OnBehalfOfContentOwner = onBehalfOfContentOwner } } func WithOnBehalfOfContentOwnerChannel(onBehalfOfContentOwnerChannel string) Option { return func(cb *channelBanner) { cb.OnBehalfOfContentOwnerChannel = onBehalfOfContentOwnerChannel } } func WithService(svc *youtube.Service) Option { return func(_ *channelBanner) { if svc == nil { svc = auth.NewY2BService( auth.WithCredential("", pkg.Root.FS()), auth.WithCacheToken("", pkg.Root.FS()), ).GetService() } service = svc } }
// 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" "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/youtube/v3" ) var ( service *youtube.Service errGetChannelSection = errors.New("failed to get channel section") errDeleteChannelSection = errors.New("failed to delete channel section") ) type channelSection struct { IDs []string `yaml:"ids" json:"ids"` ChannelId string `yaml:"channel_id" json:"channel_id"` Hl string `yaml:"hl" json:"hl"` Mine *bool `yaml:"mine" json:"mine"` OnBehalfOfContentOwner string `yaml:"on_behalf_of_content_owner" json:"on_behalf_of_content_owner"` } type ChannelSection[T any] interface { Get([]string) ([]*T, error) List([]string, string, string, io.Writer) error Delete(writer io.Writer) error // Update() // Insert() } type Option func(*channelSection) func NewChannelSection(opts ...Option) ChannelSection[youtube.ChannelSection] { cs := &channelSection{} for _, opt := range opts { opt(cs) } return cs } func (cs *channelSection) Get(parts []string) ( []*youtube.ChannelSection, error, ) { call := service.ChannelSections.List(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( parts []string, output string, jpath string, writer io.Writer, ) error { channelSections, err := cs.Get(parts) if err != nil { return err } switch output { case "json": utils.PrintJSON(channelSections, jpath, writer) case "yaml": utils.PrintYAML(channelSections, jpath, writer) case "table": tb := table.NewWriter() defer tb.Render() tb.SetOutputMirror(writer) tb.SetStyle(table.StyleLight) tb.SetAutoIndex(true) tb.AppendHeader(table.Row{"ID", "Channel ID", "Title"}) for _, chs := range channelSections { tb.AppendRow(table.Row{chs.Id, chs.Snippet.ChannelId, chs.Snippet.Title}) } } return nil } func (cs *channelSection) Delete(writer io.Writer) error { for _, id := range cs.IDs { call := 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 WithIDs(ids []string) Option { return func(cs *channelSection) { cs.IDs = ids } } func WithChannelId(channelId string) Option { return func(cs *channelSection) { cs.ChannelId = channelId } } func WithHl(hl string) Option { return func(cs *channelSection) { cs.Hl = hl } } func WithMine(mine *bool) Option { return func(cs *channelSection) { if mine != nil { cs.Mine = mine } } } func WithOnBehalfOfContentOwner(onBehalfOfContentOwner string) Option { return func(cs *channelSection) { cs.OnBehalfOfContentOwner = onBehalfOfContentOwner } } func WithService(svc *youtube.Service) Option { return func(_ *channelSection) { if svc == nil { svc = auth.NewY2BService( auth.WithCredential("", pkg.Root.FS()), auth.WithCacheToken("", pkg.Root.FS()), ).GetService() } service = svc } }
// Copyright 2025 eat-pray-ai & OpenWaygate // SPDX-License-Identifier: Apache-2.0 package comment 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/youtube/v3" ) var ( service *youtube.Service 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 { IDs []string `yaml:"ids" json:"ids"` AuthorChannelId string `yaml:"author_channel_id" json:"author_channel_id"` CanRate *bool `yaml:"can_rate" json:"can_rate"` ChannelId string `yaml:"channel_id" json:"channel_id"` MaxResults int64 `yaml:"max_results" json:"max_results"` ParentId string `yaml:"parent_id" json:"parent_id"` TextFormat string `yaml:"text_format" json:"text_format"` TextOriginal string `yaml:"text_original" json:"text_original"` ModerationStatus string `yaml:"moderation_status" json:"moderation_status"` BanAuthor *bool `yaml:"ban_author" json:"ban_author"` VideoId string `yaml:"video_id" json:"video_id"` ViewerRating string `yaml:"viewer_rating" json:"viewer_rating"` } type Comment[T any] interface { Get([]string) ([]*T, error) List([]string, string, string, io.Writer) error Insert(string, string, io.Writer) error Update(string, string, io.Writer) error Delete(io.Writer) error MarkAsSpam(string, string, io.Writer) error SetModerationStatus(string, string, io.Writer) error } type Option func(*comment) func NewComment(opts ...Option) Comment[youtube.Comment] { c := &comment{} for _, opt := range opts { opt(c) } return c } func (c *comment) Get(parts []string) ([]*youtube.Comment, error) { call := service.Comments.List(parts) if c.IDs[0] != "" { call = call.Id(c.IDs...) } if c.ParentId != "" { call = call.ParentId(c.ParentId) } if c.TextFormat != "" { call = call.TextFormat(c.TextFormat) } var items []*youtube.Comment pageToken := "" for c.MaxResults > 0 { call = call.MaxResults(min(c.MaxResults, pkg.PerPage)) c.MaxResults -= pkg.PerPage if pageToken != "" { call = call.PageToken(pageToken) } res, err := call.Do() if err != nil { return items, errors.Join(errGetComment, err) } items = append(items, res.Items...) pageToken = res.NextPageToken if pageToken == "" || len(res.Items) == 0 { break } } return items, nil } func (c *comment) List( parts []string, output string, jpath string, writer io.Writer, ) error { comments, err := c.Get(parts) if err != nil && comments == nil { return err } switch output { case "json": utils.PrintJSON(comments, jpath, writer) case "yaml": utils.PrintYAML(comments, jpath, writer) case "table": tb := table.NewWriter() defer tb.Render() tb.SetOutputMirror(writer) tb.SetStyle(table.StyleLight) tb.SetAutoIndex(true) tb.AppendHeader(table.Row{"ID", "Author", "Video ID", "Text Display"}) for _, comment := range comments { tb.AppendRow( table.Row{ comment.Id, comment.Snippet.AuthorDisplayName, comment.Snippet.VideoId, comment.Snippet.TextDisplay, }, ) } } return err } func (c *comment) Insert(output string, jpath string, writer io.Writer) error { 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 := service.Comments.Insert([]string{"snippet"}, comment) res, err := call.Do() if err != nil { return errors.Join(errInsertComment, err) } switch output { case "json": utils.PrintJSON(res, jpath, writer) case "yaml": utils.PrintYAML(res, jpath, writer) case "silent": default: _, _ = fmt.Fprintf(writer, "Comment inserted: %s\n", res.Id) } return nil } func (c *comment) Update(output string, jpath string, writer io.Writer) error { comments, err := c.Get([]string{"id", "snippet"}) 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 := service.Comments.Update([]string{"snippet"}, comment) res, err := call.Do() if err != nil { return errors.Join(errUpdateComment, err) } switch output { case "json": utils.PrintJSON(res, jpath, writer) case "yaml": utils.PrintYAML(res, jpath, writer) case "silent": default: _, _ = fmt.Fprintf(writer, "Comment updated: %s\n", res.Id) } return nil } func (c *comment) MarkAsSpam( output string, jpath string, writer io.Writer, ) error { call := service.Comments.MarkAsSpam(c.IDs) err := call.Do() if err != nil { return errors.Join(errMarkAsSpam, err) } switch output { case "json": utils.PrintJSON(c, jpath, writer) case "yaml": utils.PrintYAML(c, jpath, writer) case "silent": default: _, _ = fmt.Fprintf(writer, "Comment marked as spam: %s\n", c.IDs) } return nil } func (c *comment) SetModerationStatus( output string, jpath string, writer io.Writer, ) error { call := 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) } switch output { case "json": utils.PrintJSON(c, jpath, writer) case "yaml": utils.PrintYAML(c, jpath, writer) case "silent": default: _, _ = fmt.Fprintf( writer, "Comment moderation status set to %s: %s\n", c.ModerationStatus, c.IDs, ) } return nil } func (c *comment) Delete(writer io.Writer) error { for _, id := range c.IDs { call := 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 WithIDs(ids []string) Option { return func(c *comment) { c.IDs = ids } } 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 WithChannelId(channelId string) Option { return func(c *comment) { c.ChannelId = channelId } } func WithMaxResults(maxResults int64) Option { return func(c *comment) { if maxResults < 0 { maxResults = 1 } else if maxResults == 0 { maxResults = math.MaxInt64 } c.MaxResults = maxResults } } 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 } } func WithService(svc *youtube.Service) Option { return func(_ *comment) { if svc == nil { svc = auth.NewY2BService( auth.WithCredential("", pkg.Root.FS()), auth.WithCacheToken("", pkg.Root.FS()), ).GetService() } service = svc } }
// Copyright 2025 eat-pray-ai & OpenWaygate // SPDX-License-Identifier: Apache-2.0 package commentThread 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/youtube/v3" ) var ( service *youtube.Service errGetCommentThread = errors.New("failed to get comment thread") errInsertCommentThread = errors.New("failed to insert comment thread") ) type commentThread struct { IDs []string `yaml:"ids" json:"ids"` AllThreadsRelatedToChannelId string `yaml:"all_threads_related_to_channel_id" json:"all_threads_related_to_channel_id"` AuthorChannelId string `yaml:"author_channel_id" json:"author_channel_id"` ChannelId string `yaml:"channel_id" json:"channel_id"` MaxResults int64 `yaml:"max_results" json:"max_results"` ModerationStatus string `yaml:"moderation_status" json:"moderation_status"` Order string `yaml:"order" json:"order"` SearchTerms string `yaml:"search_terms" json:"search_terms"` TextFormat string `yaml:"text_format" json:"text_format"` TextOriginal string `yaml:"text_original" json:"text_original"` VideoId string `yaml:"video_id" json:"video_id"` } type CommentThread[T any] interface { Get([]string) ([]*T, error) List([]string, string, string, io.Writer) error Insert(output string, s string, writer io.Writer) error } type Option func(*commentThread) func NewCommentThread(opts ...Option) CommentThread[youtube.CommentThread] { c := &commentThread{} for _, opt := range opts { opt(c) } return c } func (c *commentThread) Get(parts []string) ([]*youtube.CommentThread, error) { call := service.CommentThreads.List(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) } var items []*youtube.CommentThread pageToken := "" for c.MaxResults > 0 { call = call.MaxResults(min(c.MaxResults, pkg.PerPage)) c.MaxResults -= pkg.PerPage if pageToken != "" { call = call.PageToken(pageToken) } res, err := call.Do() if err != nil { return items, errors.Join(errGetCommentThread, err) } items = append(items, res.Items...) pageToken = res.NextPageToken if pageToken == "" || len(res.Items) == 0 { break } } return items, nil } func (c *commentThread) List( parts []string, output string, jpath string, writer io.Writer, ) error { commentThreads, err := c.Get(parts) if err != nil && commentThreads == nil { return err } switch output { case "json": utils.PrintJSON(commentThreads, jpath, writer) case "yaml": utils.PrintYAML(commentThreads, jpath, writer) case "table": tb := table.NewWriter() defer tb.Render() tb.SetOutputMirror(writer) tb.SetStyle(table.StyleLight) tb.SetAutoIndex(true) tb.AppendHeader(table.Row{"ID", "Author", "Video ID", "Text Display"}) for _, cot := range commentThreads { snippet := cot.Snippet.TopLevelComment.Snippet tb.AppendRow( table.Row{ cot.Id, snippet.AuthorDisplayName, snippet.VideoId, snippet.TextDisplay, }, ) } } return err } func (c *commentThread) Insert( output string, jpath string, writer io.Writer, ) error { 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 := service.CommentThreads.Insert([]string{"snippet"}, ct).Do() if err != nil { return errors.Join(errInsertCommentThread, err) } switch output { case "json": utils.PrintJSON(res, jpath, writer) case "yaml": utils.PrintYAML(res, jpath, writer) case "silent": default: _, _ = fmt.Fprintf(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 WithChannelId(channelId string) Option { return func(c *commentThread) { c.ChannelId = channelId } } func WithIDs(ids []string) Option { return func(c *commentThread) { c.IDs = ids } } func WithMaxResults(maxResults int64) Option { return func(c *commentThread) { if maxResults < 0 { maxResults = 1 } else if maxResults == 0 { maxResults = math.MaxInt64 } c.MaxResults = maxResults } } 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 } } func WithService(svc *youtube.Service) Option { return func(_ *commentThread) { if svc == nil { svc = auth.NewY2BService( auth.WithCredential("", pkg.Root.FS()), auth.WithCacheToken("", pkg.Root.FS()), ).GetService() } service = 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" "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/youtube/v3" ) var ( service *youtube.Service errGetI18nLanguage = errors.New("failed to get i18n language") ) type i18nLanguage struct { Hl string `yaml:"hl" json:"hl"` } type I18nLanguage[T youtube.I18nLanguage] interface { Get([]string) ([]*T, error) List([]string, string, string, io.Writer) error } type Option func(*i18nLanguage) func NewI18nLanguage(opts ...Option) I18nLanguage[youtube.I18nLanguage] { i := &i18nLanguage{} for _, opt := range opts { opt(i) } return i } func (i *i18nLanguage) Get(parts []string) ( []*youtube.I18nLanguage, error, ) { call := service.I18nLanguages.List(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( parts []string, output string, jpath string, writer io.Writer, ) error { i18nLanguages, err := i.Get(parts) if err != nil { return err } switch output { case "json": utils.PrintJSON(i18nLanguages, jpath, writer) case "yaml": utils.PrintYAML(i18nLanguages, jpath, writer) case "table": tb := table.NewWriter() defer tb.Render() tb.SetOutputMirror(writer) tb.SetStyle(table.StyleLight) tb.SetAutoIndex(true) tb.AppendHeader(table.Row{"ID", "Hl", "Name"}) for _, lang := range i18nLanguages { tb.AppendRow(table.Row{lang.Id, lang.Snippet.Hl, lang.Snippet.Name}) } } return nil } func WithHl(hl string) Option { return func(i *i18nLanguage) { i.Hl = hl } } func WithService(svc *youtube.Service) Option { return func(_ *i18nLanguage) { if svc == nil { svc = auth.NewY2BService( auth.WithCredential("", pkg.Root.FS()), auth.WithCacheToken("", pkg.Root.FS()), ).GetService() } service = svc } }
// Copyright 2025 eat-pray-ai & OpenWaygate // SPDX-License-Identifier: Apache-2.0 package i18nRegion import ( "errors" "io" "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/youtube/v3" ) var ( service *youtube.Service errGetI18nRegion = errors.New("failed to get i18n region") ) type i18nRegion struct { Hl string `yaml:"hl" json:"hl"` } type I18nRegion[T any] interface { Get([]string) ([]*T, error) List([]string, string, string, io.Writer) error } type Option func(*i18nRegion) func NewI18nRegion(opts ...Option) I18nRegion[youtube.I18nRegion] { i := &i18nRegion{} for _, opt := range opts { opt(i) } return i } func (i *i18nRegion) Get(parts []string) ([]*youtube.I18nRegion, error) { call := service.I18nRegions.List(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( parts []string, output string, jpath string, writer io.Writer, ) error { i18nRegions, err := i.Get(parts) if err != nil { return err } switch output { case "json": utils.PrintJSON(i18nRegions, jpath, writer) case "yaml": utils.PrintYAML(i18nRegions, jpath, writer) case "table": tb := table.NewWriter() defer tb.Render() tb.SetOutputMirror(writer) tb.SetStyle(table.StyleLight) tb.SetAutoIndex(true) tb.AppendHeader(table.Row{"ID", "Gl", "Name"}) for _, region := range i18nRegions { tb.AppendRow(table.Row{region.Id, region.Snippet.Gl, region.Snippet.Name}) } } return nil } func WithHl(hl string) Option { return func(i *i18nRegion) { i.Hl = hl } } func WithService(svc *youtube.Service) Option { return func(_ *i18nRegion) { if svc == nil { svc = auth.NewY2BService( auth.WithCredential("", pkg.Root.FS()), auth.WithCacheToken("", pkg.Root.FS()), ).GetService() } service = svc } }
// Copyright 2025 eat-pray-ai & OpenWaygate // SPDX-License-Identifier: Apache-2.0 package member import ( "errors" "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/youtube/v3" ) var ( service *youtube.Service errGetMember = errors.New("failed to get member") ) type member struct { MemberChannelId string `yaml:"member_channel_id" json:"member_channel_id"` HasAccessToLevel string `yaml:"has_access_to_level" json:"has_access_to_level"` MaxResults int64 `yaml:"max_results" json:"max_results"` Mode string `yaml:"mode" json:"mode"` } type Member[T any] interface { List([]string, string, string, io.Writer) error Get([]string) ([]*T, error) } type Option func(*member) func NewMember(opts ...Option) Member[youtube.Member] { m := &member{} for _, opt := range opts { opt(m) } return m } func (m *member) Get(parts []string) ([]*youtube.Member, error) { call := service.Members.List(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) } var items []*youtube.Member pageToken := "" for m.MaxResults > 0 { call = call.MaxResults(min(m.MaxResults, pkg.PerPage)) m.MaxResults -= pkg.PerPage if pageToken != "" { call = call.PageToken(pageToken) } res, err := call.Do() if err != nil { return items, errors.Join(errGetMember, err) } items = append(items, res.Items...) pageToken = res.NextPageToken if pageToken == "" || len(res.Items) == 0 { break } } return items, nil } func (m *member) List( parts []string, output string, jpath string, writer io.Writer, ) error { members, err := m.Get(parts) if err != nil && members == nil { return err } switch output { case "json": utils.PrintJSON(members, jpath, writer) case "yaml": utils.PrintYAML(members, jpath, writer) case "table": tb := table.NewWriter() defer tb.Render() tb.SetOutputMirror(writer) tb.SetStyle(table.StyleLight) tb.SetAutoIndex(true) tb.AppendHeader(table.Row{"Channel ID", "Display Name"}) for _, member := range members { tb.AppendRow( table.Row{ member.Snippet.MemberDetails.ChannelId, member.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 WithMaxResults(maxResults int64) Option { return func(m *member) { if maxResults < 0 { maxResults = 1 } else if maxResults == 0 { maxResults = math.MaxInt64 } m.MaxResults = maxResults } } func WithMode(mode string) Option { return func(m *member) { m.Mode = mode } } func WithService(svc *youtube.Service) Option { return func(_ *member) { if svc == nil { svc = auth.NewY2BService( auth.WithCredential("", pkg.Root.FS()), auth.WithCacheToken("", pkg.Root.FS()), ).GetService() } service = svc } }
// Copyright 2025 eat-pray-ai & OpenWaygate // SPDX-License-Identifier: Apache-2.0 package membershipsLevel import ( "errors" "io" "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/youtube/v3" ) var ( service *youtube.Service errGetMembershipsLevel = errors.New("failed to get memberships level") ) type membershipsLevel struct{} type MembershipsLevel[T any] interface { List([]string, string, string, io.Writer) error Get([]string) ([]*T, error) } type Option func(*membershipsLevel) func NewMembershipsLevel(opts ...Option) MembershipsLevel[youtube.MembershipsLevel] { m := &membershipsLevel{} for _, opt := range opts { opt(m) } return m } func (m *membershipsLevel) Get(parts []string) ( []*youtube.MembershipsLevel, error, ) { call := service.MembershipsLevels.List(parts) res, err := call.Do() if err != nil { return nil, errors.Join(errGetMembershipsLevel, err) } return res.Items, nil } func (m *membershipsLevel) List( parts []string, output string, jpath string, writer io.Writer, ) error { membershipsLevels, err := m.Get(parts) if err != nil { return err } switch output { case "json": utils.PrintJSON(membershipsLevels, jpath, writer) case "yaml": utils.PrintYAML(membershipsLevels, jpath, writer) case "table": tb := table.NewWriter() defer tb.Render() tb.SetOutputMirror(writer) tb.SetStyle(table.StyleLight) tb.SetAutoIndex(true) tb.AppendHeader(table.Row{"ID", "Display Name"}) for _, ml := range membershipsLevels { tb.AppendRow(table.Row{ml.Id, ml.Snippet.LevelDetails.DisplayName}) } } return nil } func WithService(svc *youtube.Service) Option { return func(_ *membershipsLevel) { if svc == nil { svc = auth.NewY2BService( auth.WithCredential("", pkg.Root.FS()), auth.WithCacheToken("", pkg.Root.FS()), ).GetService() } service = svc } }
// Copyright 2025 eat-pray-ai & OpenWaygate // SPDX-License-Identifier: Apache-2.0 package playlist 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/youtube/v3" ) var ( service *youtube.Service 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 { IDs []string `yaml:"ids" json:"ids"` Title string `yaml:"title" json:"title"` Description string `yaml:"description" json:"description"` Hl string `yaml:"hl" json:"hl"` MaxResults int64 `yaml:"max_results" json:"max_results"` Mine *bool `yaml:"mine" json:"mine"` Tags []string `yaml:"tags" json:"tags"` Language string `yaml:"language" json:"language"` ChannelId string `yaml:"channel_id" json:"channel_id"` Privacy string `yaml:"privacy" json:"privacy"` OnBehalfOfContentOwner string `yaml:"on_behalf_of_content_owner" json:"on_behalf_of_content_owner"` OnBehalfOfContentOwnerChannel string `yaml:"on_behalf_of_content_owner_channel" json:"on_behalf_of_content_owner_channel"` } type Playlist[T any] interface { List([]string, string, string, io.Writer) error Insert(string, string, io.Writer) error Update(string, string, io.Writer) error Delete(io.Writer) error Get([]string) ([]*T, error) } type Option func(*playlist) func NewPlaylist(opts ...Option) Playlist[youtube.Playlist] { p := &playlist{} for _, opt := range opts { opt(p) } return p } func (p *playlist) Get(parts []string) ([]*youtube.Playlist, error) { call := service.Playlists.List(parts) if len(p.IDs) > 0 { call = call.Id(p.IDs...) } 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) } var items []*youtube.Playlist pageToken := "" for p.MaxResults > 0 { call = call.MaxResults(min(p.MaxResults, pkg.PerPage)) p.MaxResults -= pkg.PerPage if pageToken != "" { call = call.PageToken(pageToken) } res, err := call.Do() if err != nil { return items, errors.Join(errGetPlaylist, err) } items = append(items, res.Items...) pageToken = res.NextPageToken if pageToken == "" || len(res.Items) == 0 { break } } return items, nil } func (p *playlist) List( parts []string, output string, jpath string, writer io.Writer, ) error { playlists, err := p.Get(parts) if err != nil && playlists == nil { return err } switch output { case "json": utils.PrintJSON(playlists, jpath, writer) case "yaml": utils.PrintYAML(playlists, jpath, writer) case "table": tb := table.NewWriter() defer tb.Render() tb.SetOutputMirror(writer) tb.SetStyle(table.StyleLight) tb.SetAutoIndex(true) tb.AppendHeader(table.Row{"ID", "Channel ID", "Title"}) for _, pl := range playlists { tb.AppendRow(table.Row{pl.Id, pl.Snippet.ChannelId, pl.Snippet.Title}) } } return err } func (p *playlist) Insert(output string, jpath string, writer io.Writer) error { 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 := service.Playlists.Insert([]string{"snippet", "status"}, upload) res, err := call.Do() if err != nil { return errors.Join(errInsertPlaylist, err) } switch output { case "json": utils.PrintJSON(res, jpath, writer) case "yaml": utils.PrintYAML(res, jpath, writer) case "silent": default: _, _ = fmt.Fprintf(writer, "Playlist inserted: %s\n", res.Id) } return nil } func (p *playlist) Update(output string, jpath string, writer io.Writer) error { playlists, err := p.Get([]string{"id", "snippet", "status"}) 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 := service.Playlists.Update([]string{"snippet", "status"}, playlist) res, err := call.Do() if err != nil { return errors.Join(errUpdatePlaylist, err) } switch output { case "json": utils.PrintJSON(res, jpath, writer) case "yaml": utils.PrintYAML(res, jpath, writer) case "silent": default: _, _ = fmt.Fprintf(writer, "Playlist updated: %s\n", res.Id) } return nil } func (p *playlist) Delete(writer io.Writer) error { for _, id := range p.IDs { call := 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 WithIDs(ids []string) Option { return func(p *playlist) { p.IDs = ids } } 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 WithChannelId(channelId string) Option { return func(p *playlist) { p.ChannelId = channelId } } func WithPrivacy(privacy string) Option { return func(p *playlist) { p.Privacy = privacy } } func WithHl(hl string) Option { return func(p *playlist) { p.Hl = hl } } func WithMaxResults(maxResults int64) Option { return func(p *playlist) { if maxResults < 0 { maxResults = 1 } else if maxResults == 0 { maxResults = math.MaxInt64 } p.MaxResults = maxResults } } func WithMine(mine *bool) Option { return func(p *playlist) { if mine != nil { p.Mine = mine } } } func WithOnBehalfOfContentOwner(contentOwner string) Option { return func(p *playlist) { p.OnBehalfOfContentOwner = contentOwner } } func WithOnBehalfOfContentOwnerChannel(channel string) Option { return func(p *playlist) { p.OnBehalfOfContentOwnerChannel = channel } } func WithService(svc *youtube.Service) Option { return func(_ *playlist) { if svc == nil { svc = auth.NewY2BService( auth.WithCredential("", pkg.Root.FS()), auth.WithCacheToken("", pkg.Root.FS()), ).GetService() } service = svc } }
// Copyright 2025 eat-pray-ai & OpenWaygate // SPDX-License-Identifier: Apache-2.0 package playlistImage import ( "errors" "fmt" "io" "math" "os" "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/youtube/v3" ) var ( service *youtube.Service 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 { IDs []string `yaml:"ids" json:"ids"` Height int64 `yaml:"height" json:"height"` PlaylistID string `yaml:"playlistId" json:"playlistId"` Type string `yaml:"type" json:"type"` Width int64 `yaml:"width" json:"width"` File string `yaml:"file" json:"file"` Parent string `yaml:"parent" json:"parent"` MaxResults int64 `yaml:"max_results" json:"max_results"` OnBehalfOfContentOwner string `yaml:"on_behalf_of_content_owner" json:"on_behalf_of_content_owner"` OnBehalfOfContentOwnerChannel string `yaml:"on_behalf_of_content_owner_channel" json:"on_behalf_of_content_owner_channel"` } type PlaylistImage[T any] interface { Get([]string) ([]*T, error) List([]string, string, string, io.Writer) error Insert(string, string, io.Writer) error Update(string, string, io.Writer) error Delete(io.Writer) error } type Option func(*playlistImage) func NewPlaylistImage(opts ...Option) PlaylistImage[youtube.PlaylistImage] { pi := &playlistImage{} for _, opt := range opts { opt(pi) } return pi } func (pi *playlistImage) Get(parts []string) ([]*youtube.PlaylistImage, error) { call := service.PlaylistImages.List() call = call.Part(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) } var items []*youtube.PlaylistImage pageToken := "" for pi.MaxResults > 0 { call = call.MaxResults(min(pi.MaxResults, pkg.PerPage)) pi.MaxResults -= pkg.PerPage if pageToken != "" { call = call.PageToken(pageToken) } res, err := call.Do() if err != nil { return items, errors.Join(errGetPlaylistImage, err) } items = append(items, res.Items...) pageToken = res.NextPageToken if pageToken == "" || len(res.Items) == 0 { break } } return items, nil } func (pi *playlistImage) List( parts []string, output string, jpath string, writer io.Writer, ) error { playlistImages, err := pi.Get(parts) if err != nil && playlistImages == nil { return err } switch output { case "json": utils.PrintJSON(playlistImages, jpath, writer) case "yaml": utils.PrintYAML(playlistImages, jpath, writer) case "table": tb := table.NewWriter() defer tb.Render() tb.SetOutputMirror(writer) tb.SetStyle(table.StyleLight) tb.SetAutoIndex(true) tb.AppendHeader(table.Row{"ID", "Kind", "Playlist ID", "Type"}) for _, img := range playlistImages { tb.AppendRow( table.Row{img.Id, img.Kind, img.Snippet.PlaylistId, img.Snippet.Type}, ) } } return err } func (pi *playlistImage) Insert( output string, jpath string, writer io.Writer, ) error { 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 := 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) } switch output { case "json": utils.PrintJSON(res, jpath, writer) case "yaml": utils.PrintYAML(res, jpath, writer) case "silent": default: _, _ = fmt.Fprintf(writer, "PlaylistImage inserted: %s\n", res.Id) } return nil } func (pi *playlistImage) Update( output string, jpath string, writer io.Writer, ) error { playlistImages, err := pi.Get([]string{"id", "kind", "snippet"}) 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 := 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) } switch output { case "json": utils.PrintJSON(res, jpath, writer) case "yaml": utils.PrintYAML(res, jpath, writer) case "silent": default: _, _ = fmt.Fprintf(writer, "PlaylistImage updated: %s\n", res.Id) } return nil } func (pi *playlistImage) Delete(writer io.Writer) error { for _, id := range pi.IDs { call := 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 WithIDs(ids []string) Option { return func(pi *playlistImage) { pi.IDs = ids } } 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 WithMaxResults(maxResults int64) Option { return func(pi *playlistImage) { if maxResults < 0 { maxResults = 1 } else if maxResults == 0 { maxResults = math.MaxInt64 } pi.MaxResults = maxResults } } func WithOnBehalfOfContentOwner(onBehalfOfContentOwner string) Option { return func(pi *playlistImage) { pi.OnBehalfOfContentOwner = onBehalfOfContentOwner } } func WithOnBehalfOfContentOwnerChannel(onBehalfOfContentOwnerChannel string) Option { return func(pi *playlistImage) { pi.OnBehalfOfContentOwnerChannel = onBehalfOfContentOwnerChannel } } func WithService(svc *youtube.Service) Option { return func(pi *playlistImage) { if svc == nil { svc = auth.NewY2BService( auth.WithCredential("", pkg.Root.FS()), auth.WithCacheToken("", pkg.Root.FS()), ).GetService() } service = svc } }
// Copyright 2025 eat-pray-ai & OpenWaygate // SPDX-License-Identifier: Apache-2.0 package playlistItem 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/youtube/v3" ) var ( service *youtube.Service 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 { IDs []string `yaml:"ids" json:"ids"` Title string `yaml:"title" json:"title"` Description string `yaml:"description" json:"description"` Kind string `yaml:"kind" json:"kind"` KVideoId string `yaml:"k_video_id" json:"k_video_id"` KChannelId string `yaml:"k_channel_id" json:"k_channel_id"` KPlaylistId string `yaml:"k_playlist_id" json:"k_playlist_id"` VideoId string `yaml:"video_id" json:"video_id"` PlaylistId string `yaml:"playlist_id" json:"playlist_id"` ChannelId string `yaml:"channel_id" json:"channel_id"` Privacy string `yaml:"privacy" json:"privacy"` MaxResults int64 `yaml:"max_results" json:"max_results"` OnBehalfOfContentOwner string `yaml:"on_behalf_of_content_owner" json:"on_behalf_of_content_owner"` } type PlaylistItem[T any] interface { List([]string, string, string, io.Writer) error Insert(string, string, io.Writer) error Update(string, string, io.Writer) error Delete(io.Writer) error Get([]string) ([]*T, error) } type Option func(*playlistItem) func NewPlaylistItem(opts ...Option) PlaylistItem[youtube.PlaylistItem] { p := &playlistItem{} for _, opt := range opts { opt(p) } return p } func (pi *playlistItem) Get(parts []string) ([]*youtube.PlaylistItem, error) { call := service.PlaylistItems.List(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) } var items []*youtube.PlaylistItem pageToken := "" for pi.MaxResults > 0 { call = call.MaxResults(min(pi.MaxResults, pkg.PerPage)) pi.MaxResults -= pkg.PerPage if pageToken != "" { call = call.PageToken(pageToken) } res, err := call.Do() if err != nil { return items, errors.Join(errGetPlaylistItem, err) } items = append(items, res.Items...) pageToken = res.NextPageToken if pageToken == "" || len(res.Items) == 0 { break } } return items, nil } func (pi *playlistItem) List( parts []string, output string, jpath string, writer io.Writer, ) error { playlistItems, err := pi.Get(parts) if err != nil && playlistItems == nil { return err } switch output { case "json": utils.PrintJSON(playlistItems, jpath, writer) case "yaml": utils.PrintYAML(playlistItems, jpath, writer) case "table": tb := table.NewWriter() defer tb.Render() tb.SetOutputMirror(writer) tb.SetStyle(table.StyleLight) tb.SetAutoIndex(true) tb.AppendHeader(table.Row{"ID", "Title", "Kind", "Resource ID"}) for _, item := range playlistItems { var resourceId string switch item.Snippet.ResourceId.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 } tb.AppendRow( table.Row{ item.Id, item.Snippet.Title, item.Snippet.ResourceId.Kind, resourceId, }, ) } } return err } func (pi *playlistItem) Insert( output string, jpath string, writer io.Writer, ) error { 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 := 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) } switch output { case "json": utils.PrintJSON(res, jpath, writer) case "yaml": utils.PrintYAML(res, jpath, writer) case "silent": default: _, _ = fmt.Fprintf(writer, "Playlist Item inserted: %s\n", res.Id) } return nil } func (pi *playlistItem) Update( output string, jpath string, writer io.Writer, ) error { playlistItems, err := pi.Get([]string{"id", "snippet", "status"}) 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 := 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) } switch output { case "json": utils.PrintJSON(res, jpath, writer) case "yaml": utils.PrintYAML(res, jpath, writer) case "silent": default: _, _ = fmt.Fprintf(writer, "Playlist Item updated: %s\n", res.Id) } return nil } func (pi *playlistItem) Delete(writer io.Writer) error { for _, id := range pi.IDs { call := 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, "Playlsit Item %s deleted", id) } return nil } func WithIDs(ids []string) Option { return func(p *playlistItem) { p.IDs = ids } } 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 WithChannelId(channelId string) Option { return func(p *playlistItem) { p.ChannelId = channelId } } func WithPrivacy(privacy string) Option { return func(p *playlistItem) { p.Privacy = privacy } } func WithMaxResults(maxResults int64) Option { return func(p *playlistItem) { if maxResults < 0 { maxResults = 1 } else if maxResults == 0 { maxResults = math.MaxInt64 } p.MaxResults = maxResults } } func WithOnBehalfOfContentOwner(onBehalfOfContentOwner string) Option { return func(p *playlistItem) { p.OnBehalfOfContentOwner = onBehalfOfContentOwner } } func WithService(svc *youtube.Service) Option { return func(_ *playlistItem) { if svc == nil { svc = auth.NewY2BService( auth.WithCredential("", pkg.Root.FS()), auth.WithCacheToken("", pkg.Root.FS()), ).GetService() } service = svc } }
// Copyright 2025 eat-pray-ai & OpenWaygate // SPDX-License-Identifier: Apache-2.0 package search import ( "errors" "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/youtube/v3" ) var ( service *youtube.Service errGetSearch = errors.New("failed to get search") ) type search struct { ChannelId string `yaml:"channel_id" json:"channel_id"` ChannelType string `yaml:"channel_type" json:"channel_type"` EventType string `yaml:"event_type" json:"event_type"` ForContentOwner *bool `yaml:"for_content_owner" json:"for_content_owner"` ForDeveloper *bool `yaml:"for_developer" json:"for_developer"` ForMine *bool `yaml:"for_mine" json:"for_mine"` Location string `yaml:"location" json:"location"` LocationRadius string `yaml:"location_radius" json:"location_radius"` MaxResults int64 `yaml:"max_results" json:"max_results"` OnBehalfOfContentOwner string `yaml:"on_behalf_of_content_owner" json:"on_behalf_of_content_owner"` Order string `yaml:"order" json:"order"` PublishedAfter string `yaml:"published_after" json:"published_after"` PublishedBefore string `yaml:"published_before" json:"published_before"` Q string `yaml:"q" json:"q"` RegionCode string `yaml:"region_code" json:"region_code"` RelevanceLanguage string `yaml:"relevance_language" json:"relevance_language"` SafeSearch string `yaml:"safe_search" json:"safe_search"` TopicId string `yaml:"topic_id" json:"topic_id"` Types []string `yaml:"types" json:"types"` VideoCaption string `yaml:"video_caption" json:"video_caption"` VideoCategoryId string `yaml:"video_category_id" json:"video_category_id"` VideoDefinition string `yaml:"video_definition" json:"video_definition"` VideoDimension string `yaml:"video_dimension" json:"video_dimension"` VideoDuration string `yaml:"video_duration" json:"video_duration"` VideoEmbeddable string `yaml:"video_embeddable" json:"video_embeddable"` VideoLicense string `yaml:"video_license" json:"video_license"` VideoPaidProductPlacement string `yaml:"video_paid_product_placement" json:"video_paid_product_placement"` VideoSyndicated string `yaml:"video_syndicated" json:"video_syndicated"` VideoType string `yaml:"video_type" json:"video_type"` } type Search[T any] interface { Get([]string) ([]*T, error) List([]string, string, string, io.Writer) error } type Option func(*search) func NewSearch(opts ...Option) Search[youtube.SearchResult] { s := &search{} for _, opt := range opts { opt(s) } return s } func (s *search) Get(parts []string) ([]*youtube.SearchResult, error) { call := service.Search.List(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) } var items []*youtube.SearchResult pageToken := "" for s.MaxResults > 0 { call = call.MaxResults(min(s.MaxResults, pkg.PerPage)) s.MaxResults -= pkg.PerPage if pageToken != "" { call = call.PageToken(pageToken) } res, err := call.Do() if err != nil { return items, errors.Join(errGetSearch, err) } items = append(items, res.Items...) pageToken = res.NextPageToken if pageToken == "" || len(res.Items) == 0 { break } } return items, nil } func (s *search) List( parts []string, output string, jpath string, writer io.Writer, ) error { results, err := s.Get(parts) if err != nil && results == nil { return err } switch output { case "json": utils.PrintJSON(results, jpath, writer) case "yaml": utils.PrintYAML(results, jpath, writer) case "table": tb := table.NewWriter() defer tb.Render() tb.SetOutputMirror(writer) tb.SetStyle(table.StyleLight) tb.SetAutoIndex(true) tb.AppendHeader(table.Row{"Kind", "Title", "Resource ID"}) for _, result := range results { var resourceId string switch result.Id.Kind { case "youtube#video": resourceId = result.Id.VideoId case "youtube#channel": resourceId = result.Id.ChannelId case "youtube#playlist": resourceId = result.Id.PlaylistId } tb.AppendRow( table.Row{result.Id.Kind, result.Snippet.Title, resourceId}, ) } } return err } func WithChannelId(channelId string) Option { return func(s *search) { s.ChannelId = channelId } } 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 WithMaxResults(maxResults int64) Option { return func(s *search) { if maxResults < 0 { maxResults = 1 } else if maxResults == 0 { maxResults = math.MaxInt64 } s.MaxResults = maxResults } } func WithOnBehalfOfContentOwner(onBehalfOfContentOwner string) Option { return func(s *search) { s.OnBehalfOfContentOwner = onBehalfOfContentOwner } } 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 } } func WithService(svc *youtube.Service) Option { return func(_ *search) { if svc == nil { svc = auth.NewY2BService( auth.WithCredential("", pkg.Root.FS()), auth.WithCacheToken("", pkg.Root.FS()), ).GetService() } service = svc } }
// Copyright 2025 eat-pray-ai & OpenWaygate // SPDX-License-Identifier: Apache-2.0 package subscription 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/youtube/v3" ) var ( service *youtube.Service errGetSubscription = errors.New("failed to get subscription") errDeleteSubscription = errors.New("failed to delete subscription") errInsertSubscription = errors.New("failed to insert subscription") ) type subscription struct { IDs []string `yaml:"ids" json:"ids"` SubscriberChannelId string `yaml:"subscriber_channel_id" json:"subscriber_channel_id"` Description string `yaml:"description" json:"description"` ChannelId string `yaml:"channel_id" json:"channel_id"` ForChannelId string `yaml:"for_channel_id" json:"for_channel_id"` MaxResults int64 `yaml:"max_results" json:"max_results"` Mine *bool `yaml:"mine" json:"mine"` MyRecentSubscribers *bool `yaml:"my_recent_subscribers" json:"my_recent_subscribers"` MySubscribers *bool `yaml:"my_subscribers" json:"my_subscribers"` OnBehalfOfContentOwner string `yaml:"on_behalf_of_content_owner" json:"on_behalf_of_content_owner"` OnBehalfOfContentOwnerChannel string `yaml:"on_behalf_of_content_owner_channel" json:"on_behalf_of_content_owner_channel"` Order string `yaml:"order" json:"order"` Title string `yaml:"title" json:"title"` } type Subscription[T any] interface { Get([]string) ([]*T, error) List([]string, string, string, io.Writer) error Insert(string, string, io.Writer) error Delete(io.Writer) error } type Option func(*subscription) func NewSubscription(opts ...Option) Subscription[youtube.Subscription] { s := &subscription{} for _, opt := range opts { opt(s) } return s } func (s *subscription) Get(parts []string) ([]*youtube.Subscription, error) { call := service.Subscriptions.List(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) } var items []*youtube.Subscription pageToken := "" for s.MaxResults > 0 { call = call.MaxResults(min(s.MaxResults, pkg.PerPage)) s.MaxResults -= pkg.PerPage if pageToken != "" { call = call.PageToken(pageToken) } res, err := call.Do() if err != nil { return items, errors.Join(errGetSubscription, err) } items = append(items, res.Items...) pageToken = res.NextPageToken if pageToken == "" || len(res.Items) == 0 { break } } return items, nil } func (s *subscription) List( parts []string, output string, jpath string, writer io.Writer, ) error { subscriptions, err := s.Get(parts) if err != nil && subscriptions == nil { return err } switch output { case "json": utils.PrintJSON(subscriptions, jpath, writer) case "yaml": utils.PrintYAML(subscriptions, jpath, writer) case "table": tb := table.NewWriter() defer tb.Render() tb.SetOutputMirror(writer) tb.SetStyle(table.StyleLight) tb.SetAutoIndex(true) tb.AppendHeader(table.Row{"ID", "Kind", "Resource ID", "Channel Title"}) for _, sub := range subscriptions { 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 } tb.AppendRow( table.Row{ sub.Id, sub.Snippet.ResourceId.Kind, resourceId, sub.Snippet.Title, }, ) } } return err } func (s *subscription) Insert( output string, jpath string, writer io.Writer, ) error { subscription := &youtube.Subscription{ Snippet: &youtube.SubscriptionSnippet{ ChannelId: s.SubscriberChannelId, Description: s.Description, ResourceId: &youtube.ResourceId{ ChannelId: s.ChannelId, }, Title: s.Title, }, } call := service.Subscriptions.Insert([]string{"snippet"}, subscription) res, err := call.Do() if err != nil { return errors.Join(errInsertSubscription, err) } switch output { case "json": utils.PrintJSON(res, jpath, writer) case "yaml": utils.PrintYAML(res, jpath, writer) default: _, _ = fmt.Fprintf(writer, "Subscription inserted: %s\n", res.Id) } return nil } func (s *subscription) Delete(writer io.Writer) error { for _, id := range s.IDs { call := 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 WithIDs(ids []string) Option { return func(s *subscription) { s.IDs = ids } } 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 WithChannelId(channelId string) Option { return func(s *subscription) { s.ChannelId = channelId } } func WithForChannelId(forChannelId string) Option { return func(s *subscription) { s.ForChannelId = forChannelId } } func WithMaxResults(maxResults int64) Option { return func(s *subscription) { if maxResults < 0 { maxResults = 1 } else if maxResults == 0 { maxResults = math.MaxInt64 } s.MaxResults = maxResults } } 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 WithOnBehalfOfContentOwner(onBehalfOfContentOwner string) Option { return func(s *subscription) { s.OnBehalfOfContentOwner = onBehalfOfContentOwner } } 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 } } func WithService(svc *youtube.Service) Option { return func(_ *subscription) { if svc == nil { svc = auth.NewY2BService( auth.WithCredential("", pkg.Root.FS()), auth.WithCacheToken("", pkg.Root.FS()), ).GetService() } service = svc } }
// Copyright 2025 eat-pray-ai & OpenWaygate // SPDX-License-Identifier: Apache-2.0 package superChatEvent import ( "errors" "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/youtube/v3" ) var ( service *youtube.Service errGetSuperChatEvent = errors.New("failed to get super chat event") ) type superChatEvent struct { Hl string `yaml:"hl" json:"hl"` MaxResults int64 `yaml:"max_results" json:"max_results"` } type SuperChatEvent[T any] interface { Get([]string) ([]*T, error) List([]string, string, string, io.Writer) error } type Option func(*superChatEvent) func NewSuperChatEvent(opts ...Option) SuperChatEvent[youtube.SuperChatEvent] { s := &superChatEvent{} for _, opt := range opts { opt(s) } return s } func (s *superChatEvent) Get(parts []string) ([]*youtube.SuperChatEvent, error) { call := service.SuperChatEvents.List(parts) if s.Hl != "" { call = call.Hl(s.Hl) } var items []*youtube.SuperChatEvent pageToken := "" for s.MaxResults > 0 { call = call.MaxResults(min(s.MaxResults, pkg.PerPage)) s.MaxResults -= pkg.PerPage if pageToken != "" { call = call.PageToken(pageToken) } res, err := call.Do() if err != nil { return items, errors.Join(errGetSuperChatEvent, err) } items = append(items, res.Items...) pageToken = res.NextPageToken if pageToken == "" || len(res.Items) == 0 { break } } return items, nil } func (s *superChatEvent) List( parts []string, output string, jpath string, writer io.Writer, ) error { events, err := s.Get(parts) if err != nil && events == nil { return err } switch output { case "json": utils.PrintJSON(events, jpath, writer) case "yaml": utils.PrintYAML(events, jpath, writer) case "table": tb := table.NewWriter() defer tb.Render() tb.SetOutputMirror(writer) tb.SetStyle(table.StyleLight) tb.SetAutoIndex(true) tb.AppendHeader(table.Row{"ID", "Amount", "Comment", "Supporter"}) for _, event := range events { tb.AppendRow( table.Row{ event.Id, event.Snippet.DisplayString, event.Snippet.CommentText, event.Snippet.SupporterDetails.DisplayName, }, ) } } return err } func WithHl(hl string) Option { return func(s *superChatEvent) { s.Hl = hl } } func WithMaxResults(maxResults int64) Option { return func(s *superChatEvent) { if maxResults < 0 { maxResults = 1 } else if maxResults == 0 { maxResults = math.MaxInt64 } s.MaxResults = maxResults } } func WithService(svc *youtube.Service) Option { return func(_ *superChatEvent) { if svc == nil { svc = auth.NewY2BService( auth.WithCredential("", pkg.Root.FS()), auth.WithCacheToken("", pkg.Root.FS()), ).GetService() } service = svc } }
// Copyright 2025 eat-pray-ai & OpenWaygate // SPDX-License-Identifier: Apache-2.0 package thumbnail import ( "errors" "fmt" "io" "github.com/eat-pray-ai/yutu/pkg" "github.com/eat-pray-ai/yutu/pkg/auth" "github.com/eat-pray-ai/yutu/pkg/utils" "google.golang.org/api/youtube/v3" ) var ( service *youtube.Service errSetThumbnail = errors.New("failed to set thumbnail") ) type thumbnail struct { File string `yaml:"file" json:"file"` VideoId string `yaml:"video_id" json:"video_id"` } type Thumbnail interface { Set(string, string, io.Writer) error } type Option func(*thumbnail) func NewThumbnail(opts ...Option) Thumbnail { t := &thumbnail{} for _, opt := range opts { opt(t) } return t } func (t *thumbnail) Set(output string, jpath string, writer io.Writer) error { file, err := pkg.Root.Open(t.File) if err != nil { return errors.Join(errSetThumbnail, err) } call := service.Thumbnails.Set(t.VideoId).Media(file) res, err := call.Do() if err != nil { return errors.Join(errSetThumbnail, err) } switch output { case "json": utils.PrintJSON(res, jpath, writer) case "yaml": utils.PrintYAML(res, jpath, writer) case "silent": default: _, _ = fmt.Fprintf(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 } } func WithService(svc *youtube.Service) Option { return func(_ *thumbnail) { if svc == nil { svc = auth.NewY2BService( auth.WithCredential("", pkg.Root.FS()), auth.WithCacheToken("", pkg.Root.FS()), ).GetService() } service = svc } }
// 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" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/ohler55/ojg/jp" "github.com/spf13/pflag" "gopkg.in/yaml.v3" ) func PrintJSON(data interface{}, jpath string, writer io.Writer) { j, err := jp.ParseString(jpath) if err != nil && jpath != "" { _, _ = fmt.Fprintln(writer, "Invalid JSONPath:", jpath) return } else if jpath != "" { data = j.Get(data) } marshalled, _ := json.MarshalIndent(data, "", " ") _, _ = fmt.Fprintln(writer, string(marshalled)) } func PrintYAML(data interface{}, jpath string, writer io.Writer) { j, err := jp.ParseString(jpath) if err != nil && jpath != "" { _, _ = fmt.Fprintln(writer, "Invalid JSONPath:", jpath) return } else if jpath != "" { data = j.Get(data) } 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 BoolPtr(b string) *bool { if b == "" { return nil } val := b == "true" return &val } 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 Errf(format string, args ...any) *mcp.CallToolResult { return &mcp.CallToolResult{ Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf(format, args...)}}, IsError: true, } }
// Copyright 2025 eat-pray-ai & OpenWaygate // SPDX-License-Identifier: Apache-2.0 package pkg import ( "log/slog" "os" ) const ( PartsUsage = "Comma separated parts" MRUsage = "The maximum number of items that should be returned, 0 for no limit" TableUsage = "json, yaml, or table" SilentUsage = "json, yaml, or silent" JPUsage = "JSONPath expression to filter the output" JsonMIME = "application/json" PerPage = 20 ) var ( Root *os.Root Logger *slog.Logger ) func init() { var err error rootDir, ok := os.LookupEnv("YUTU_ROOT") if !ok { rootDir = "/" } Root, err = os.OpenRoot(rootDir) if err != nil { panic(err) } logLevel := slog.LevelWarn 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.Stdout, &slog.HandlerOptions{ Level: logLevel, }, ), ) slog.SetDefault(Logger) }
// Copyright 2025 eat-pray-ai & OpenWaygate // SPDX-License-Identifier: Apache-2.0 package video import ( "errors" "fmt" "io" "math" "os" "slices" "github.com/eat-pray-ai/yutu/pkg" "github.com/eat-pray-ai/yutu/pkg/auth" "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 ( service *youtube.Service 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 { IDs []string `yaml:"ids" json:"ids"` AutoLevels *bool `yaml:"auto_levels" json:"auto_levels"` File string `yaml:"file" json:"file"` Title string `yaml:"title" json:"title"` Description string `yaml:"description" json:"description"` Hl string `yaml:"hl" json:"hl"` Tags []string `yaml:"tags" json:"tags"` Language string `yaml:"language" json:"language"` Locale string `yaml:"locale" json:"locale"` License string `yaml:"license" json:"license"` Thumbnail string `yaml:"thumbnail" json:"thumbnail"` Rating string `yaml:"rating" json:"rating"` Chart string `yaml:"chart" json:"chart"` ChannelId string `yaml:"channel_id" json:"channel_id"` Comments string `yaml:"comments" json:"comments"` PlaylistId string `yaml:"playlist_id" json:"playlist_id"` CategoryId string `yaml:"category_id" json:"category_id"` Privacy string `yaml:"privacy" json:"privacy"` ForKids *bool `yaml:"for_kids" json:"for_kids"` Embeddable *bool `yaml:"embeddable" json:"embeddable"` PublishAt string `yaml:"publish_at" json:"publish_at"` RegionCode string `yaml:"region_code" json:"region_code"` ReasonId string `yaml:"reason_id" json:"reason_id"` SecondaryReasonId string `yaml:"secondary_reason_id" json:"secondary_reason_id"` Stabilize *bool `yaml:"stabilize" json:"stabilize"` MaxHeight int64 `yaml:"max_height" json:"max_height"` MaxWidth int64 `yaml:"max_width" json:"max_width"` MaxResults int64 `yaml:"max_results" json:"max_results"` NotifySubscribers *bool `yaml:"notify_subscribers" json:"notify_subscribers"` PublicStatsViewable *bool `yaml:"public_stats_viewable" json:"public_stats_viewable"` OnBehalfOfContentOwner string `yaml:"on_behalf_of_content_owner" json:"on_behalf_of_content_owner"` OnBehalfOfContentOwnerChannel string `yaml:"on_behalf_of_content_owner_channel" json:"on_behalf_of_content_owner_channel"` } type Video[T any] interface { List([]string, string, string, io.Writer) error Insert(string, string, io.Writer) error Update(string, string, io.Writer) error Rate(io.Writer) error GetRating(string, string, io.Writer) error Delete(io.Writer) error ReportAbuse(io.Writer) error Get([]string) ([]*T, error) } type Option func(*video) func NewVideo(opts ...Option) Video[youtube.Video] { v := &video{} for _, opt := range opts { opt(v) } return v } func (v *video) Get(parts []string) ([]*youtube.Video, error) { call := service.Videos.List(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) } var items []*youtube.Video pageToken := "" for v.MaxResults > 0 { call = call.MaxResults(min(v.MaxResults, pkg.PerPage)) v.MaxResults -= pkg.PerPage if pageToken != "" { call = call.PageToken(pageToken) } res, err := call.Do() if err != nil { return items, errors.Join(errGetVideo, err) } items = append(items, res.Items...) pageToken = res.NextPageToken if pageToken == "" || len(res.Items) == 0 { break } } return items, nil } func (v *video) List( parts []string, output string, jpath string, writer io.Writer, ) error { videos, err := v.Get(parts) if err != nil && videos == nil { return err } switch output { case "json": utils.PrintJSON(videos, jpath, writer) case "yaml": utils.PrintYAML(videos, jpath, writer) case "table": tb := table.NewWriter() defer tb.Render() tb.SetOutputMirror(writer) tb.SetStyle(table.StyleLight) tb.SetAutoIndex(true) tb.AppendHeader(table.Row{"ID", "Title", "Channel ID", "Views"}) for _, video := range videos { tb.AppendRow( table.Row{ video.Id, video.Snippet.Title, video.Snippet.ChannelId, video.Statistics.ViewCount, }, ) } } return err } func (v *video) Insert(output string, jpath string, writer io.Writer) error { 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 := 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(service), ) _ = t.Set("silent", "", 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(service), ) _ = pi.Insert("silent", "", writer) } switch output { case "json": utils.PrintJSON(res, jpath, writer) case "yaml": utils.PrintYAML(res, jpath, writer) case "silent": default: _, _ = fmt.Fprintf(writer, "Video inserted: %s\n", res.Id) } return nil } func (v *video) Update(output string, jpath string, writer io.Writer) error { videos, err := v.Get([]string{"id", "snippet", "status"}) 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 := 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(service), ) _ = t.Set("silent", "", 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(service), ) _ = pi.Insert("silent", "", writer) } switch output { case "json": utils.PrintJSON(res, jpath, writer) case "yaml": utils.PrintYAML(res, jpath, writer) case "silent": default: _, _ = fmt.Fprintf(writer, "Video updated: %s\n", res.Id) } return nil } func (v *video) Rate(writer io.Writer) error { for _, id := range v.IDs { call := 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(output string, jpath string, writer io.Writer) error { call := service.Videos.GetRating(v.IDs) if v.OnBehalfOfContentOwner != "" { call = call.OnBehalfOfContentOwner(v.OnBehalfOfContentOwnerChannel) } res, err := call.Do() if err != nil { return errors.Join(errGetRating, err) } switch output { case "json": utils.PrintJSON(res.Items, jpath, writer) case "yaml": utils.PrintYAML(res.Items, jpath, writer) default: tb := table.NewWriter() defer tb.Render() tb.SetOutputMirror(writer) tb.SetStyle(table.StyleLight) tb.SetAutoIndex(true) 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 { for _, id := range v.IDs { call := 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 { for _, id := range v.IDs { videoAbuseReport := &youtube.VideoAbuseReport{ Comments: v.Comments, Language: v.Language, ReasonId: v.ReasonId, SecondaryReasonId: v.SecondaryReasonId, VideoId: id, } call := 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 WithIDs(ids []string) Option { return func(v *video) { v.IDs = ids } } 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 WithHl(hl string) Option { return func(v *video) { v.Hl = hl } } 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 WithChannelId(channelId string) Option { return func(v *video) { v.ChannelId = channelId } } 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 WithMaxResults(maxResults int64) Option { return func(v *video) { if maxResults < 0 { maxResults = 1 } else if maxResults == 0 { maxResults = math.MaxInt64 } v.MaxResults = maxResults } } func WithNotifySubscribers(notifySubscribers *bool) Option { return func(v *video) { if notifySubscribers != nil { v.NotifySubscribers = notifySubscribers } } } func WithOnBehalfOfContentOwner(onBehalfOfContentOwner string) Option { return func(v *video) { v.OnBehalfOfContentOwner = onBehalfOfContentOwner } } 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 } } func WithService(svc *youtube.Service) Option { return func(_ *video) { if svc == nil { svc = auth.NewY2BService( auth.WithCredential("", pkg.Root.FS()), auth.WithCacheToken("", pkg.Root.FS()), ).GetService() } service = svc } }
// Copyright 2025 eat-pray-ai & OpenWaygate // SPDX-License-Identifier: Apache-2.0 package videoAbuseReportReason import ( "errors" "io" "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/youtube/v3" ) var ( service *youtube.Service errGetVideoAbuseReportReason = errors.New("failed to get video abuse report reason") ) type videoAbuseReportReason struct { Hl string `yaml:"hl" json:"hl"` } type VideoAbuseReportReason[T any] interface { Get([]string) ([]*T, error) List([]string, string, string, io.Writer) error } type Option func(*videoAbuseReportReason) func NewVideoAbuseReportReason(opt ...Option) VideoAbuseReportReason[youtube.VideoAbuseReportReason] { va := &videoAbuseReportReason{} for _, o := range opt { o(va) } return va } func (va *videoAbuseReportReason) Get(parts []string) ( []*youtube.VideoAbuseReportReason, error, ) { call := service.VideoAbuseReportReasons.List(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( parts []string, output string, jpath string, writer io.Writer, ) error { videoAbuseReportReasons, err := va.Get(parts) if err != nil { return err } switch output { case "json": utils.PrintJSON(videoAbuseReportReasons, jpath, writer) case "yaml": utils.PrintYAML(videoAbuseReportReasons, jpath, writer) case "table": tb := table.NewWriter() defer tb.Render() tb.SetOutputMirror(writer) tb.SetStyle(table.StyleLight) tb.SetAutoIndex(true) tb.AppendHeader(table.Row{"ID", "Label"}) for _, reason := range videoAbuseReportReasons { tb.AppendRow(table.Row{reason.Id, reason.Snippet.Label}) } } return nil } func WithHL(hl string) Option { return func(vc *videoAbuseReportReason) { vc.Hl = hl } } func WithService(svc *youtube.Service) Option { return func(_ *videoAbuseReportReason) { if svc == nil { svc = auth.NewY2BService( auth.WithCredential("", pkg.Root.FS()), auth.WithCacheToken("", pkg.Root.FS()), ).GetService() } service = svc } }
// Copyright 2025 eat-pray-ai & OpenWaygate // SPDX-License-Identifier: Apache-2.0 package videoCategory import ( "errors" "io" "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/youtube/v3" ) var ( service *youtube.Service errGetVideoCategory = errors.New("failed to get video categoryId") ) type videoCategory struct { IDs []string `yaml:"ids" json:"ids"` Hl string `yaml:"hl" json:"hl"` RegionCode string `yaml:"region_code" json:"region_code"` } type VideoCategory[T any] interface { Get([]string) ([]*T, error) List([]string, string, string, io.Writer) error } type Option func(*videoCategory) func NewVideoCategory(opt ...Option) VideoCategory[youtube.VideoCategory] { vc := &videoCategory{} for _, o := range opt { o(vc) } return vc } func (vc *videoCategory) Get(parts []string) ([]*youtube.VideoCategory, error) { call := service.VideoCategories.List(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( parts []string, output string, jpath string, writer io.Writer, ) error { videoCategories, err := vc.Get(parts) if err != nil { return err } switch output { case "json": utils.PrintJSON(videoCategories, jpath, writer) case "yaml": utils.PrintYAML(videoCategories, jpath, writer) case "table": tb := table.NewWriter() defer tb.Render() tb.SetOutputMirror(writer) tb.SetStyle(table.StyleLight) tb.SetAutoIndex(true) tb.AppendHeader(table.Row{"ID", "Title", "Assignable"}) for _, cat := range videoCategories { tb.AppendRow(table.Row{cat.Id, cat.Snippet.Title, cat.Snippet.Assignable}) } } return nil } func WithIDs(ids []string) Option { return func(vc *videoCategory) { vc.IDs = ids } } func WithHl(hl string) Option { return func(vc *videoCategory) { vc.Hl = hl } } func WithRegionCode(regionCode string) Option { return func(vc *videoCategory) { vc.RegionCode = regionCode } } func WithService(svc *youtube.Service) Option { return func(_ *videoCategory) { if svc == nil { svc = auth.NewY2BService( auth.WithCredential("", pkg.Root.FS()), auth.WithCacheToken("", pkg.Root.FS()), ).GetService() } service = svc } }
// 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/auth" "google.golang.org/api/youtube/v3" ) var ( service *youtube.Service errSetWatermark = errors.New("failed to set watermark") errUnsetWatermark = errors.New("failed to unset watermark") ) type watermark struct { ChannelId string `yaml:"channel_id" json:"channel_id"` File string `yaml:"file" json:"file"` InVideoPosition string `yaml:"in_video_position" json:"in_video_position"` DurationMs uint64 `yaml:"duration_ms" json:"duration_ms"` OffsetMs uint64 `yaml:"offset_ms" json:"offset_ms"` OffsetType string `yaml:"offset_type" json:"offset_type"` OnBehalfOfContentOwner string `yaml:"on_behalf_of_content_owner" json:"on_behalf_of_content_owner"` } type Watermark interface { Set(io.Writer) error Unset(io.Writer) error } type Option func(*watermark) func NewWatermark(opts ...Option) Watermark { w := &watermark{} for _, opt := range opts { opt(w) } return w } func (w *watermark) Set(writer io.Writer) error { 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 := 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 { call := 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 WithChannelId(channelId string) Option { return func(w *watermark) { w.ChannelId = channelId } } 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 } } func WithOnBehalfOfContentOwner(onBehalfOfContentOwner string) Option { return func(w *watermark) { w.OnBehalfOfContentOwner = onBehalfOfContentOwner } } func WithService(svc *youtube.Service) Option { return func(_ *watermark) { if svc == nil { svc = auth.NewY2BService( auth.WithCredential("", pkg.Root.FS()), auth.WithCacheToken("", pkg.Root.FS()), ).GetService() } service = svc } }