// 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/common"
"github.com/eat-pray-ai/yutu/pkg/utils"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetActivity = errors.New("failed to get activity")
)
type Activity struct {
*common.Fields
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 IActivity[T any] interface {
List(io.Writer) error
Get() ([]*T, error)
}
type Option func(*Activity)
func NewActivity(opts ...Option) IActivity[youtube.Activity] {
a := &Activity{Fields: &common.Fields{}}
for _, opt := range opts {
opt(a)
}
return a
}
func (a *Activity) Get() ([]*youtube.Activity, error) {
a.EnsureService()
call := a.Service.Activities.List(a.Parts)
if a.ChannelId != "" {
call = call.ChannelId(a.ChannelId)
}
if a.Home != nil {
call = call.Home(*a.Home)
}
if a.Mine != nil {
call = call.Mine(*a.Mine)
}
if a.PublishedAfter != "" {
call = call.PublishedAfter(a.PublishedAfter)
}
if a.PublishedBefore != "" {
call = call.PublishedBefore(a.PublishedBefore)
}
if a.RegionCode != "" {
call = call.RegionCode(a.RegionCode)
}
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(writer io.Writer) error {
activities, err := a.Get()
if err != nil && activities == nil {
return err
}
switch a.Output {
case "json":
utils.PrintJSON(activities, a.Jsonpath, writer)
case "yaml":
utils.PrintYAML(activities, a.Jsonpath, writer)
case "table":
tb := table.NewWriter()
defer tb.Render()
tb.SetOutputMirror(writer)
tb.SetStyle(pkg.TableStyle)
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
}
}
var (
WithParts = common.WithParts[*Activity]
WithOutput = common.WithOutput[*Activity]
WithJsonpath = common.WithJsonpath[*Activity]
WithService = common.WithService[*Activity]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package auth
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/eat-pray-ai/yutu/pkg"
"github.com/eat-pray-ai/yutu/pkg/utils"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/option"
"google.golang.org/api/youtube/v3"
)
const (
createSvcFailed = "failed to create YouTube service"
parseUrlFailed = "failed to parse redirect URL"
stateMatchFailed = "state doesn't match, possible CSRF attack"
readPromptFailed = "failed to read prompt"
exchangeFailed = "failed to exchange token"
listenFailed = "failed to start web server"
cacheTokenFailed = "failed to cache token"
parseTokenFailed = "failed to parse token"
refreshTokenFailed = "failed to refresh token, please re-authenticate in cli"
parseSecretFailed = "failed to parse client secret"
browserOpenedHint = "Your browser has been opened to an authorization URL. yutu will resume once authorization has been provided.\n%s\n"
openBrowserHint = "It seems that your browser is not open. Go to the following link in your browser:\n%s\n"
receivedCodeHint = "Authorization code received: %s\nYou can now safely close the browser window.\n"
manualInputHint = `
After completing the authorization flow, enter the authorization code on command line.
If you end up in an error page after completing the authorization flow,
and the url in the address bar is in the form of
'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 (
scope = []string{
youtube.YoutubeScope,
youtube.YoutubeForceSslScope,
youtube.YoutubeChannelMembershipsCreatorScope,
}
)
func (s *svc) GetService() (*youtube.Service, error) {
if s.initErr != nil {
return nil, s.initErr
}
client, err := s.refreshClient()
if err != nil {
return nil, err
}
service, err := youtube.NewService(s.ctx, option.WithHTTPClient(client))
if err != nil {
return nil, fmt.Errorf("%s: %w", createSvcFailed, err)
}
s.service = service
return s.service, nil
}
func (s *svc) refreshClient() (client *http.Client, err error) {
config, err := s.getConfig()
if err != nil {
return nil, err
}
authedToken := &oauth2.Token{}
err = json.Unmarshal([]byte(s.CacheToken), authedToken)
if err != nil {
client, authedToken, err = s.newClient(config)
if err != nil {
return nil, err
}
if s.tokenFile != "" {
if err := s.saveToken(authedToken); err != nil {
return nil, err
}
}
return client, nil
}
if !authedToken.Valid() {
tokenSource := config.TokenSource(s.ctx, authedToken)
authedToken, err = tokenSource.Token()
if err != nil && s.tokenFile != "" {
client, authedToken, err = s.newClient(config)
if err != nil {
return nil, err
}
if err := s.saveToken(authedToken); err != nil {
return nil, err
}
return client, nil
} else if err != nil {
return nil, fmt.Errorf("%s: %w", refreshTokenFailed, err)
}
if authedToken != nil && s.tokenFile != "" {
if err := s.saveToken(authedToken); err != nil {
return nil, err
}
}
return config.Client(s.ctx, authedToken), nil
}
return config.Client(s.ctx, authedToken), nil
}
func (s *svc) newClient(config *oauth2.Config) (
client *http.Client, token *oauth2.Token, err error,
) {
verifier := oauth2.GenerateVerifier()
authURL := config.AuthCodeURL(
s.state,
oauth2.AccessTypeOffline,
oauth2.S256ChallengeOption(verifier),
)
token, err = s.getTokenFromWeb(config, authURL, verifier)
if err != nil {
return nil, nil, err
}
client = config.Client(s.ctx, token)
return client, token, nil
}
func (s *svc) getConfig() (*oauth2.Config, error) {
config, err := google.ConfigFromJSON([]byte(s.Credential), scope...)
if err != nil {
return nil, fmt.Errorf("%s: %w", parseSecretFailed, err)
}
return config, nil
}
func (s *svc) startWebServer(redirectURL string) (chan string, error) {
u, err := url.Parse(redirectURL)
if err != nil {
return nil, fmt.Errorf("%s: %w", parseUrlFailed, err)
}
listener, err := net.Listen("tcp", u.Host)
if err != nil {
return nil, fmt.Errorf("%s: %w", listenFailed, err)
}
codeCh := make(chan string)
go func() {
_ = http.Serve(
listener, http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
return
}
state := r.FormValue("state")
if state != s.state {
slog.Error(
stateMatchFailed, "actual", state, "expected", s.state,
)
return
}
code := r.FormValue("code")
codeCh <- code
_ = listener.Close()
w.Header().Set("Content-Type", "text/plain")
_, _ = fmt.Fprintf(w, receivedCodeHint, code)
},
),
)
}()
return codeCh, nil
}
func (s *svc) getCodeFromPrompt(authURL string) (code string, err error) {
_, _ = fmt.Fprintf(s.out, openBrowserHint, authURL)
_, _ = fmt.Fprint(s.out, manualInputHint)
_, err = fmt.Fscan(s.in, &code)
if err != nil {
return "", fmt.Errorf("%s: %w", readPromptFailed, err)
}
if strings.HasPrefix(code, "4%2F") {
code = strings.Replace(code, "4%2F", "4/", 1)
}
return code, nil
}
func (s *svc) getTokenFromWeb(
config *oauth2.Config, authURL string, verifier string,
) (*oauth2.Token, error) {
codeCh, err := s.startWebServer(config.RedirectURL)
if err != nil {
return nil, err
}
var code string
if err := utils.OpenURL(authURL); err == nil {
_, _ = fmt.Fprintf(s.out, browserOpenedHint, authURL)
code = <-codeCh
}
if code == "" {
code, err = s.getCodeFromPrompt(authURL)
if err != nil {
return nil, err
}
}
slog.Debug("Authorization code generated", "code", code)
token, err := config.Exchange(
context.TODO(), code, oauth2.VerifierOption(verifier),
)
if err != nil {
return nil, fmt.Errorf("%s: %w", exchangeFailed, err)
}
return token, nil
}
func (s *svc) saveToken(token *oauth2.Token) error {
dir := filepath.Dir(s.tokenFile)
if err := pkg.Root.MkdirAll(dir, 0755); err != nil {
slog.Error(cacheTokenFailed, "dir", dir, "error", err)
return fmt.Errorf("%s: %w", cacheTokenFailed, err)
}
f, err := pkg.Root.OpenFile(
s.tokenFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600,
)
if err != nil {
slog.Error(cacheTokenFailed, "file", s.tokenFile, "error", err)
return fmt.Errorf("%s: %w", cacheTokenFailed, err)
}
defer func() {
_ = f.Close()
}()
err = json.NewEncoder(f).Encode(token)
if err != nil {
return fmt.Errorf("%s: %w", cacheTokenFailed, err)
}
slog.Debug("Token cached to file", "file", s.tokenFile)
return nil
}
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package auth
import (
"context"
"encoding/base64"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/eat-pray-ai/yutu/pkg"
"github.com/eat-pray-ai/yutu/pkg/utils"
"google.golang.org/api/youtube/v3"
)
const (
readTokenFailed = "failed to read token"
readSecretFailed = "failed to read client secret"
authHint = "Please configure client secret as described in https://github.com/eat-pray-ai/yutu#prerequisites"
)
type svc struct {
Credential string `yaml:"credential" json:"credential"`
CacheToken string `yaml:"cache_token" json:"cache_token"`
credFile string
tokenFile string
initErr error
in io.Reader
out io.Writer
service *youtube.Service
ctx context.Context
state string
}
type Svc interface {
GetService() (*youtube.Service, error)
}
type Option func(*svc)
func NewY2BService(opts ...Option) Svc {
s := &svc{}
s.ctx = context.Background()
s.credFile = "client_secret.json"
s.state = utils.RandomStage()
s.in = os.Stdin
s.out = os.Stdout
for _, opt := range opts {
opt(s)
}
return s
}
func WithIO(in io.Reader, out io.Writer) Option {
return func(s *svc) {
s.in = in
s.out = out
}
}
func WithCredential(cred string, fsys fs.FS) Option {
return func(s *svc) {
// cred > YUTU_CREDENTIAL
envCred, ok := os.LookupEnv("YUTU_CREDENTIAL")
if cred == "" && ok {
cred = envCred
} else if cred == "" {
cred = s.credFile
}
// 1. cred is a file path
// 2. cred is a base64 encoded string
// 3. cred is a JSON string
absCred, _ := filepath.Abs(cred)
relCred, _ := filepath.Rel(*pkg.RootDir, absCred)
relCred = strings.ReplaceAll(relCred, `\`, `/`)
if _, err := fs.Stat(fsys, relCred); err == nil {
s.credFile = absCred
credBytes, err := fs.ReadFile(fsys, relCred)
if err != nil {
s.initErr = fmt.Errorf("%s: %w", readSecretFailed, err)
slog.Error(
readSecretFailed, "hint", authHint, "path", absCred, "error", err,
)
}
s.Credential = string(credBytes)
return
}
if credB64, err := base64.StdEncoding.DecodeString(cred); err == nil {
s.Credential = string(credB64)
} else if utils.IsJson(cred) {
s.Credential = cred
} else {
s.initErr = fmt.Errorf("%s: %w", parseSecretFailed, err)
slog.Error(parseSecretFailed, "hint", authHint, "error", err)
}
}
}
func WithCacheToken(token string, fsys fs.FS) Option {
return func(s *svc) {
// token > YUTU_CACHE_TOKEN
envToken, ok := os.LookupEnv("YUTU_CACHE_TOKEN")
if token == "" && ok {
token = envToken
} else if token == "" {
token = "youtube.token.json"
}
// 1. token is a file path
// 2. token is a base64 encoded string
// 3. token is a JSON string
absToken, _ := filepath.Abs(token)
relToken, _ := filepath.Rel(*pkg.RootDir, absToken)
relToken = strings.ReplaceAll(relToken, `\`, `/`)
if _, err := fs.Stat(fsys, relToken); err == nil {
tokenBytes, err := fs.ReadFile(fsys, relToken)
if err != nil {
slog.Warn(readTokenFailed, "path", absToken, "error", err)
}
s.tokenFile = relToken
s.CacheToken = string(tokenBytes)
return
} else if os.IsNotExist(err) && strings.HasSuffix(token, ".json") {
s.tokenFile = relToken
return
}
if tokenB64, err := base64.StdEncoding.DecodeString(token); err == nil {
s.CacheToken = string(tokenB64)
} else if utils.IsJson(token) {
s.CacheToken = token
} else {
slog.Warn(parseTokenFailed, "token", token, "error", err)
}
}
}
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package caption
import (
"errors"
"fmt"
"io"
"os"
"github.com/eat-pray-ai/yutu/pkg"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/eat-pray-ai/yutu/pkg/utils"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetCaption = errors.New("failed to get caption")
errUpdateCaption = errors.New("failed to update caption")
errDeleteCaption = errors.New("failed to delete caption")
errInsertCaption = errors.New("failed to insert caption")
errDownloadCaption = errors.New("failed to download caption")
)
type Caption struct {
*common.Fields
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 ICaption[T youtube.Caption] interface {
Get() ([]*T, error)
List(io.Writer) error
Insert(io.Writer) error
Update(io.Writer) error
Delete(io.Writer) error
Download(io.Writer) error
}
type Option func(*Caption)
func NewCaption(opts ...Option) ICaption[youtube.Caption] {
c := &Caption{Fields: &common.Fields{}}
for _, opt := range opts {
opt(c)
}
return c
}
func (c *Caption) GetFields() *common.Fields {
return c.Fields
}
func (c *Caption) Get() ([]*youtube.Caption, error) {
c.EnsureService()
call := c.Service.Captions.List(c.Parts, c.VideoId)
if len(c.Ids) > 0 {
call = call.Id(c.Ids...)
}
if c.OnBehalfOf != "" {
call = call.OnBehalfOf(c.OnBehalfOf)
}
if c.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(c.OnBehalfOfContentOwner)
}
res, err := call.Do()
if err != nil {
return nil, errors.Join(errGetCaption, err)
}
return res.Items, nil
}
func (c *Caption) List(writer io.Writer) error {
captions, err := c.Get()
if err != nil {
return err
}
switch c.Output {
case "json":
utils.PrintJSON(captions, c.Jsonpath, writer)
case "yaml":
utils.PrintYAML(captions, c.Jsonpath, writer)
case "table":
tb := table.NewWriter()
tb.SetOutputMirror(writer)
tb.SetStyle(pkg.TableStyle)
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(writer io.Writer) error {
c.EnsureService()
file, err := pkg.Root.Open(c.File)
if err != nil {
return errors.Join(errInsertCaption, err)
}
defer func(file *os.File) {
_ = file.Close()
}(file)
caption := &youtube.Caption{
Snippet: &youtube.CaptionSnippet{
AudioTrackType: c.AudioTrackType,
IsAutoSynced: c.IsAutoSynced != nil && *c.IsAutoSynced,
IsCC: c.IsCC != nil && *c.IsCC,
IsDraft: c.IsDraft != nil && *c.IsDraft,
IsEasyReader: c.IsEasyReader != nil && *c.IsEasyReader,
IsLarge: c.IsLarge != nil && *c.IsLarge,
Language: c.Language,
Name: c.Name,
TrackKind: c.TrackKind,
VideoId: c.VideoId,
},
}
call := c.Service.Captions.Insert([]string{"snippet"}, caption).Media(file)
if c.OnBehalfOf != "" {
call = call.OnBehalfOf(c.OnBehalfOf)
}
if c.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(c.OnBehalfOfContentOwner)
}
res, err := call.Do()
if err != nil {
return errors.Join(errInsertCaption, err)
}
switch c.Output {
case "json":
utils.PrintJSON(res, c.Jsonpath, writer)
case "yaml":
utils.PrintYAML(res, c.Jsonpath, writer)
case "silent":
default:
_, _ = fmt.Fprintf(writer, "Caption inserted: %s\n", res.Id)
}
return nil
}
func (c *Caption) Update(writer io.Writer) error {
c.Parts = []string{"snippet"}
captions, err := c.Get()
if err != nil {
return errors.Join(errUpdateCaption, err)
}
if len(captions) == 0 {
return errGetCaption
}
caption := captions[0]
if c.AudioTrackType != "" {
caption.Snippet.AudioTrackType = c.AudioTrackType
}
if c.IsAutoSynced != nil {
caption.Snippet.IsAutoSynced = *c.IsAutoSynced
}
if c.IsCC != nil {
caption.Snippet.IsCC = *c.IsCC
}
if c.IsDraft != nil {
caption.Snippet.IsDraft = *c.IsDraft
}
if c.IsEasyReader != nil {
caption.Snippet.IsEasyReader = *c.IsEasyReader
}
if c.IsLarge != nil {
caption.Snippet.IsLarge = *c.IsLarge
}
if c.Language != "" {
caption.Snippet.Language = c.Language
}
if c.Name != "" {
caption.Snippet.Name = c.Name
}
if c.TrackKind != "" {
caption.Snippet.TrackKind = c.TrackKind
}
if c.VideoId != "" {
caption.Snippet.VideoId = c.VideoId
}
call := c.Service.Captions.Update([]string{"snippet"}, caption)
if c.File != "" {
file, err := pkg.Root.Open(c.File)
if err != nil {
return errors.Join(errUpdateCaption, err)
}
defer func(file *os.File) {
_ = file.Close()
}(file)
call = call.Media(file)
}
if c.OnBehalfOf != "" {
call = call.OnBehalfOf(c.OnBehalfOf)
}
if c.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(c.OnBehalfOfContentOwner)
}
res, err := call.Do()
if err != nil {
return errors.Join(errUpdateCaption, err)
}
switch c.Output {
case "json":
utils.PrintJSON(res, c.Jsonpath, writer)
case "yaml":
utils.PrintYAML(res, c.Jsonpath, writer)
case "silent":
default:
_, _ = fmt.Fprintf(writer, "Caption updated: %s\n", res.Id)
}
return nil
}
func (c *Caption) Delete(writer io.Writer) error {
c.EnsureService()
for _, id := range c.Ids {
call := c.Service.Captions.Delete(id)
if c.OnBehalfOf != "" {
call = call.OnBehalfOf(c.OnBehalfOf)
}
if c.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(c.OnBehalfOfContentOwner)
}
err := call.Do()
if err != nil {
return errors.Join(errDeleteCaption, err)
}
_, _ = fmt.Fprintf(writer, "Caption %s deleted\n", id)
}
return nil
}
func (c *Caption) Download(writer io.Writer) error {
c.EnsureService()
call := c.Service.Captions.Download(c.Ids[0])
if c.Tfmt != "" {
call = call.Tfmt(c.Tfmt)
}
if c.Tlang != "" {
call = call.Tlang(c.Tlang)
}
if c.OnBehalfOf != "" {
call = call.OnBehalfOf(c.OnBehalfOf)
}
if c.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(c.OnBehalfOfContentOwner)
}
res, err := call.Download()
if err != nil {
return errors.Join(errDownloadCaption, err)
}
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(res.Body)
body, err := io.ReadAll(res.Body)
if err != nil {
return errors.Join(errDownloadCaption, err)
}
file, err := os.Create(c.File)
if err != nil {
return errors.Join(errDownloadCaption, err)
}
defer func(file *os.File) {
_ = file.Close()
}(file)
_, err = file.Write(body)
if err != nil {
return errors.Join(errDownloadCaption, err)
}
_, _ = fmt.Fprintf(writer, "Caption %s downloaded to %s\n", c.Ids[0], c.File)
return nil
}
func 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
}
}
var (
WithParts = common.WithParts[*Caption]
WithOutput = common.WithOutput[*Caption]
WithJsonpath = common.WithJsonpath[*Caption]
WithService = common.WithService[*Caption]
)
// 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/common"
"github.com/eat-pray-ai/yutu/pkg/utils"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetChannel = errors.New("failed to get channel")
errUpdateChannel = errors.New("failed to update channel")
)
type Channel struct {
*common.Fields
CategoryId string `yaml:"category_id" json:"category_id"`
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 IChannel[T youtube.Channel] interface {
List(io.Writer) error
Update(io.Writer) error
Get() ([]*T, error)
}
type Option func(*Channel)
func NewChannel(opts ...Option) IChannel[youtube.Channel] {
c := &Channel{Fields: &common.Fields{}}
for _, opt := range opts {
opt(c)
}
return c
}
func (c *Channel) Get() ([]*youtube.Channel, error) {
c.EnsureService()
call := c.Service.Channels.List(c.Parts)
if c.CategoryId != "" {
call = call.CategoryId(c.CategoryId)
}
if c.ForHandle != "" {
call = call.ForHandle(c.ForHandle)
}
if c.ForUsername != "" {
call = call.ForUsername(c.ForUsername)
}
if c.Hl != "" {
call = call.Hl(c.Hl)
}
if len(c.Ids) > 0 {
call = call.Id(c.Ids...)
}
if c.ManagedByMe != nil {
call = call.ManagedByMe(*c.ManagedByMe)
}
if c.Mine != nil {
call = call.Mine(*c.Mine)
}
if c.MySubscribers != nil {
call = call.MySubscribers(*c.MySubscribers)
}
if c.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(c.OnBehalfOfContentOwner)
}
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(writer io.Writer) error {
channels, err := c.Get()
if err != nil && channels == nil {
return err
}
switch c.Output {
case "json":
utils.PrintJSON(channels, c.Jsonpath, writer)
case "yaml":
utils.PrintYAML(channels, c.Jsonpath, writer)
case "table":
tb := table.NewWriter()
defer tb.Render()
tb.SetOutputMirror(writer)
tb.SetStyle(pkg.TableStyle)
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(writer io.Writer) error {
c.Parts = []string{"snippet"}
channels, err := c.Get()
if err != nil {
return errors.Join(errUpdateChannel, err)
}
if len(channels) == 0 {
return errGetChannel
}
cha := channels[0]
if c.Country != "" {
cha.Snippet.Country = c.Country
}
if c.CustomUrl != "" {
cha.Snippet.CustomUrl = c.CustomUrl
}
if c.DefaultLanguage != "" {
cha.Snippet.DefaultLanguage = c.DefaultLanguage
}
if c.Description != "" {
cha.Snippet.Description = c.Description
}
if c.Title != "" {
cha.Snippet.Title = c.Title
}
call := c.Service.Channels.Update(c.Parts, cha)
res, err := call.Do()
if err != nil {
return errors.Join(errUpdateChannel, err)
}
switch c.Output {
case "json":
utils.PrintJSON(res, c.Jsonpath, writer)
case "yaml":
utils.PrintYAML(res, c.Jsonpath, 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
}
}
var (
WithParts = common.WithParts[*Channel]
WithOutput = common.WithOutput[*Channel]
WithJsonpath = common.WithJsonpath[*Channel]
WithService = common.WithService[*Channel]
)
// 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/common"
"github.com/eat-pray-ai/yutu/pkg/utils"
"google.golang.org/api/youtube/v3"
)
var (
errInsertChannelBanner = errors.New("failed to insert channelBanner")
)
type ChannelBanner struct {
*common.Fields
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 IChannelBanner interface {
Insert(io.Writer) error
}
type Option func(banner *ChannelBanner)
func NewChannelBanner(opts ...Option) IChannelBanner {
cb := &ChannelBanner{Fields: &common.Fields{}}
for _, opt := range opts {
opt(cb)
}
return cb
}
func (cb *ChannelBanner) Insert(writer io.Writer) error {
cb.EnsureService()
file, err := pkg.Root.Open(cb.File)
if err != nil {
return errors.Join(errInsertChannelBanner, err)
}
defer func(file *os.File) {
_ = file.Close()
}(file)
cbr := &youtube.ChannelBannerResource{}
call := cb.Service.ChannelBanners.Insert(cbr).ChannelId(cb.ChannelId).Media(file)
if cb.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(cb.OnBehalfOfContentOwner)
}
if cb.OnBehalfOfContentOwnerChannel != "" {
call = call.OnBehalfOfContentOwnerChannel(cb.OnBehalfOfContentOwnerChannel)
}
res, err := call.Do()
if err != nil {
return errors.Join(errInsertChannelBanner, err)
}
switch cb.Output {
case "json":
utils.PrintJSON(res, cb.Jsonpath, writer)
case "yaml":
utils.PrintYAML(res, cb.Jsonpath, 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
}
}
var (
WithOutput = common.WithOutput[*ChannelBanner]
WithJsonpath = common.WithJsonpath[*ChannelBanner]
WithService = common.WithService[*ChannelBanner]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package channelSection
import (
"errors"
"fmt"
"io"
"github.com/eat-pray-ai/yutu/pkg"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/eat-pray-ai/yutu/pkg/utils"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetChannelSection = errors.New("failed to get channel section")
errDeleteChannelSection = errors.New("failed to delete channel section")
)
type ChannelSection struct {
*common.Fields
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 IChannelSection[T any] interface {
Get() ([]*T, error)
List(io.Writer) error
Delete(io.Writer) error
// Update()
// Insert()
}
type Option func(*ChannelSection)
func NewChannelSection(opts ...Option) IChannelSection[youtube.ChannelSection] {
cs := &ChannelSection{Fields: &common.Fields{}}
for _, opt := range opts {
opt(cs)
}
return cs
}
func (cs *ChannelSection) GetFields() *common.Fields {
return cs.Fields
}
func (cs *ChannelSection) Get() (
[]*youtube.ChannelSection, error,
) {
cs.EnsureService()
call := cs.Service.ChannelSections.List(cs.Parts)
if len(cs.Ids) > 0 {
call = call.Id(cs.Ids...)
}
if cs.ChannelId != "" {
call = call.ChannelId(cs.ChannelId)
}
if cs.Hl != "" {
call = call.Hl(cs.Hl)
}
if cs.Mine != nil {
call = call.Mine(*cs.Mine)
}
if cs.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(cs.OnBehalfOfContentOwner)
}
res, err := call.Do()
if err != nil {
return nil, errors.Join(errGetChannelSection, err)
}
return res.Items, nil
}
func (cs *ChannelSection) List(writer io.Writer) error {
channelSections, err := cs.Get()
if err != nil {
return err
}
switch cs.Output {
case "json":
utils.PrintJSON(channelSections, cs.Jsonpath, writer)
case "yaml":
utils.PrintYAML(channelSections, cs.Jsonpath, writer)
case "table":
tb := table.NewWriter()
defer tb.Render()
tb.SetOutputMirror(writer)
tb.SetStyle(pkg.TableStyle)
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 {
cs.EnsureService()
for _, id := range cs.Ids {
call := cs.Service.ChannelSections.Delete(id)
if cs.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(cs.OnBehalfOfContentOwner)
}
err := call.Do()
if err != nil {
return errors.Join(errDeleteChannelSection, err)
}
_, _ = fmt.Fprintf(writer, "Channel section %s deleted\n", id)
}
return nil
}
func 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
}
}
var (
WithParts = common.WithParts[*ChannelSection]
WithOutput = common.WithOutput[*ChannelSection]
WithJsonpath = common.WithJsonpath[*ChannelSection]
WithService = common.WithService[*ChannelSection]
)
// 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/common"
"github.com/eat-pray-ai/yutu/pkg/utils"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetComment = errors.New("failed to get comment")
errMarkAsSpam = errors.New("failed to mark comment as spam")
errDeleteComment = errors.New("failed to delete comment")
errInsertComment = errors.New("failed to insert comment")
errUpdateComment = errors.New("failed to update comment")
errSetModerationStatus = errors.New("failed to set comment moderation status")
)
type Comment struct {
*common.Fields
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 IComment[T any] interface {
Get() ([]*T, error)
List(io.Writer) error
Insert(io.Writer) error
Update(io.Writer) error
Delete(io.Writer) error
MarkAsSpam(io.Writer) error
SetModerationStatus(io.Writer) error
}
type Option func(*Comment)
func NewComment(opts ...Option) IComment[youtube.Comment] {
c := &Comment{Fields: &common.Fields{}}
for _, opt := range opts {
opt(c)
}
return c
}
func (c *Comment) Get() ([]*youtube.Comment, error) {
c.EnsureService()
call := c.Service.Comments.List(c.Parts)
if len(c.Ids) > 0 && c.Ids[0] != "" {
call = call.Id(c.Ids...)
}
if c.ParentId != "" {
call = call.ParentId(c.ParentId)
}
if c.TextFormat != "" {
call = call.TextFormat(c.TextFormat)
}
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(writer io.Writer) error {
comments, err := c.Get()
if err != nil && comments == nil {
return err
}
switch c.Output {
case "json":
utils.PrintJSON(comments, c.Jsonpath, writer)
case "yaml":
utils.PrintYAML(comments, c.Jsonpath, writer)
case "table":
tb := table.NewWriter()
defer tb.Render()
tb.SetOutputMirror(writer)
tb.SetStyle(pkg.TableStyle)
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(writer io.Writer) error {
c.EnsureService()
comment := &youtube.Comment{
Snippet: &youtube.CommentSnippet{
AuthorChannelId: &youtube.CommentSnippetAuthorChannelId{
Value: c.AuthorChannelId,
},
ChannelId: c.ChannelId,
ParentId: c.ParentId,
TextOriginal: c.TextOriginal,
VideoId: c.VideoId,
},
}
if c.CanRate != nil {
comment.Snippet.CanRate = *c.CanRate
}
call := c.Service.Comments.Insert([]string{"snippet"}, comment)
res, err := call.Do()
if err != nil {
return errors.Join(errInsertComment, err)
}
switch c.Output {
case "json":
utils.PrintJSON(res, c.Jsonpath, writer)
case "yaml":
utils.PrintYAML(res, c.Jsonpath, writer)
case "silent":
default:
_, _ = fmt.Fprintf(writer, "Comment inserted: %s\n", res.Id)
}
return nil
}
func (c *Comment) Update(writer io.Writer) error {
c.EnsureService()
c.Parts = []string{"id", "snippet"}
comments, err := c.Get()
if err != nil {
return errors.Join(errUpdateComment, err)
}
if len(comments) == 0 {
return errGetComment
}
comment := comments[0]
if c.CanRate != nil {
comment.Snippet.CanRate = *c.CanRate
}
if c.TextOriginal != "" {
comment.Snippet.TextOriginal = c.TextOriginal
}
if c.ViewerRating != "" {
comment.Snippet.ViewerRating = c.ViewerRating
}
call := c.Service.Comments.Update([]string{"snippet"}, comment)
res, err := call.Do()
if err != nil {
return errors.Join(errUpdateComment, err)
}
switch c.Output {
case "json":
utils.PrintJSON(res, c.Jsonpath, writer)
case "yaml":
utils.PrintYAML(res, c.Jsonpath, writer)
case "silent":
default:
_, _ = fmt.Fprintf(writer, "Comment updated: %s\n", res.Id)
}
return nil
}
func (c *Comment) MarkAsSpam(writer io.Writer) error {
c.EnsureService()
call := c.Service.Comments.MarkAsSpam(c.Ids)
err := call.Do()
if err != nil {
return errors.Join(errMarkAsSpam, err)
}
switch c.Output {
case "json":
utils.PrintJSON(c, c.Jsonpath, writer)
case "yaml":
utils.PrintYAML(c, c.Jsonpath, writer)
case "silent":
default:
_, _ = fmt.Fprintf(writer, "Comment marked as spam: %s\n", c.Ids)
}
return nil
}
func (c *Comment) SetModerationStatus(writer io.Writer) error {
c.EnsureService()
call := c.Service.Comments.SetModerationStatus(c.Ids, c.ModerationStatus)
if c.BanAuthor != nil {
call = call.BanAuthor(*c.BanAuthor)
}
err := call.Do()
if err != nil {
return errors.Join(errSetModerationStatus, err)
}
switch c.Output {
case "json":
utils.PrintJSON(c, c.Jsonpath, writer)
case "yaml":
utils.PrintYAML(c, c.Jsonpath, 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 {
c.EnsureService()
for _, id := range c.Ids {
call := c.Service.Comments.Delete(id)
err := call.Do()
if err != nil {
return errors.Join(errDeleteComment, err)
}
_, _ = fmt.Fprintf(writer, "Comment %s deleted\n", id)
}
return nil
}
func 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
}
}
var (
WithParts = common.WithParts[*Comment]
WithOutput = common.WithOutput[*Comment]
WithJsonpath = common.WithJsonpath[*Comment]
WithService = common.WithService[*Comment]
)
// 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/common"
"github.com/eat-pray-ai/yutu/pkg/utils"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetCommentThread = errors.New("failed to get comment thread")
errInsertCommentThread = errors.New("failed to insert comment thread")
)
type CommentThread struct {
*common.Fields
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 ICommentThread[T any] interface {
Get() ([]*T, error)
List(io.Writer) error
Insert(io.Writer) error
}
type Option func(*CommentThread)
func NewCommentThread(opts ...Option) ICommentThread[youtube.CommentThread] {
c := &CommentThread{Fields: &common.Fields{}}
for _, opt := range opts {
opt(c)
}
return c
}
func (c *CommentThread) Get() ([]*youtube.CommentThread, error) {
c.EnsureService()
call := c.Service.CommentThreads.List(c.Parts)
if len(c.Ids) > 0 {
call = call.Id(c.Ids...)
}
if c.AllThreadsRelatedToChannelId != "" {
call = call.AllThreadsRelatedToChannelId(c.AllThreadsRelatedToChannelId)
}
if c.ChannelId != "" {
call = call.ChannelId(c.ChannelId)
}
if c.ModerationStatus != "" {
call = call.ModerationStatus(c.ModerationStatus)
}
if c.Order != "" {
call = call.Order(c.Order)
}
if c.SearchTerms != "" {
call = call.SearchTerms(c.SearchTerms)
}
if c.TextFormat != "" {
call = call.TextFormat(c.TextFormat)
}
if c.VideoId != "" {
call = call.VideoId(c.VideoId)
}
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(writer io.Writer) error {
commentThreads, err := c.Get()
if err != nil && commentThreads == nil {
return err
}
switch c.Output {
case "json":
utils.PrintJSON(commentThreads, c.Jsonpath, writer)
case "yaml":
utils.PrintYAML(commentThreads, c.Jsonpath, writer)
case "table":
tb := table.NewWriter()
defer tb.Render()
tb.SetOutputMirror(writer)
tb.SetStyle(pkg.TableStyle)
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(writer io.Writer) error {
c.EnsureService()
ct := &youtube.CommentThread{
Snippet: &youtube.CommentThreadSnippet{
ChannelId: c.ChannelId,
TopLevelComment: &youtube.Comment{
Snippet: &youtube.CommentSnippet{
AuthorChannelId: &youtube.CommentSnippetAuthorChannelId{
Value: c.AuthorChannelId,
},
ChannelId: c.ChannelId,
TextOriginal: c.TextOriginal,
VideoId: c.VideoId,
},
},
},
}
res, err := c.Service.CommentThreads.Insert([]string{"snippet"}, ct).Do()
if err != nil {
return errors.Join(errInsertCommentThread, err)
}
switch c.Output {
case "json":
utils.PrintJSON(res, c.Jsonpath, writer)
case "yaml":
utils.PrintYAML(res, c.Jsonpath, 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
}
}
var (
WithParts = common.WithParts[*CommentThread]
WithOutput = common.WithOutput[*CommentThread]
WithJsonpath = common.WithJsonpath[*CommentThread]
WithService = common.WithService[*CommentThread]
)
package common
import (
"fmt"
"github.com/eat-pray-ai/yutu/pkg"
"github.com/eat-pray-ai/yutu/pkg/auth"
"google.golang.org/api/youtube/v3"
)
type Fields struct {
Service *youtube.Service
Parts []string `yaml:"parts" json:"parts"`
Output string `yaml:"output" json:"output"`
Jsonpath string `yaml:"jsonpath" json:"jsonpath"`
}
func (d *Fields) GetFields() *Fields {
return d
}
func (d *Fields) EnsureService() {
if d.Service == nil {
svc, err := auth.NewY2BService(
auth.WithCredential("", pkg.Root.FS()),
auth.WithCacheToken("", pkg.Root.FS()),
).GetService()
if err != nil {
panic(fmt.Sprintf("failed to create YouTube service: %v", err))
}
d.Service = svc
}
}
type HasFields interface {
GetFields() *Fields
EnsureService()
}
func WithParts[T HasFields](parts []string) func(T) {
return func(t T) {
t.GetFields().Parts = parts
}
}
func WithOutput[T HasFields](output string) func(T) {
return func(t T) {
t.GetFields().Output = output
}
}
func WithJsonpath[T HasFields](jsonpath string) func(T) {
return func(t T) {
t.GetFields().Jsonpath = jsonpath
}
}
func WithService[T HasFields](svc *youtube.Service) func(T) {
return func(t T) {
t.GetFields().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/common"
"github.com/eat-pray-ai/yutu/pkg/utils"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetI18nLanguage = errors.New("failed to get i18n language")
)
type I18nLanguage struct {
*common.Fields
Hl string `yaml:"hl" json:"hl"`
}
type II18nLanguage[T youtube.I18nLanguage] interface {
Get() ([]*T, error)
List(io.Writer) error
}
type Option func(*I18nLanguage)
func NewI18nLanguage(opts ...Option) II18nLanguage[youtube.I18nLanguage] {
i := &I18nLanguage{Fields: &common.Fields{}}
for _, opt := range opts {
opt(i)
}
return i
}
func (i *I18nLanguage) Get() (
[]*youtube.I18nLanguage, error,
) {
i.EnsureService()
call := i.Service.I18nLanguages.List(i.Parts)
if i.Hl != "" {
call = call.Hl(i.Hl)
}
res, err := call.Do()
if err != nil {
return nil, errors.Join(errGetI18nLanguage, err)
}
return res.Items, nil
}
func (i *I18nLanguage) List(writer io.Writer) error {
i18nLanguages, err := i.Get()
if err != nil {
return err
}
switch i.Output {
case "json":
utils.PrintJSON(i18nLanguages, i.Jsonpath, writer)
case "yaml":
utils.PrintYAML(i18nLanguages, i.Jsonpath, writer)
case "table":
tb := table.NewWriter()
defer tb.Render()
tb.SetOutputMirror(writer)
tb.SetStyle(pkg.TableStyle)
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
}
}
var (
WithParts = common.WithParts[*I18nLanguage]
WithOutput = common.WithOutput[*I18nLanguage]
WithJsonpath = common.WithJsonpath[*I18nLanguage]
WithService = common.WithService[*I18nLanguage]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package i18nRegion
import (
"errors"
"io"
"github.com/eat-pray-ai/yutu/pkg"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/eat-pray-ai/yutu/pkg/utils"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetI18nRegion = errors.New("failed to get i18n region")
)
type I18nRegion struct {
*common.Fields
Hl string `yaml:"hl" json:"hl"`
}
type II18nRegion[T any] interface {
Get() ([]*T, error)
List(io.Writer) error
}
type Option func(*I18nRegion)
func NewI18nRegion(opts ...Option) II18nRegion[youtube.I18nRegion] {
i := &I18nRegion{Fields: &common.Fields{}}
for _, opt := range opts {
opt(i)
}
return i
}
func (i *I18nRegion) Get() ([]*youtube.I18nRegion, error) {
i.EnsureService()
call := i.Service.I18nRegions.List(i.Parts)
if i.Hl != "" {
call = call.Hl(i.Hl)
}
res, err := call.Do()
if err != nil {
return nil, errors.Join(errGetI18nRegion, err)
}
return res.Items, nil
}
func (i *I18nRegion) List(writer io.Writer) error {
i18nRegions, err := i.Get()
if err != nil {
return err
}
switch i.Output {
case "json":
utils.PrintJSON(i18nRegions, i.Jsonpath, writer)
case "yaml":
utils.PrintYAML(i18nRegions, i.Jsonpath, writer)
case "table":
tb := table.NewWriter()
defer tb.Render()
tb.SetOutputMirror(writer)
tb.SetStyle(pkg.TableStyle)
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
}
}
var (
WithParts = common.WithParts[*I18nRegion]
WithOutput = common.WithOutput[*I18nRegion]
WithJsonpath = common.WithJsonpath[*I18nRegion]
WithService = common.WithService[*I18nRegion]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package pkg
import (
"log/slog"
"os"
"runtime"
)
var (
RootDir *string
Root *os.Root
logger *slog.Logger
)
func init() {
if logger == nil {
initLogger()
}
if RootDir == nil {
initRootDir()
}
}
func initLogger() {
logLevel := slog.LevelInfo
if lvl, ok := os.LookupEnv("YUTU_LOG_LEVEL"); ok {
switch lvl {
case "DEBUG", "debug":
logLevel = slog.LevelDebug
case "INFO", "info":
logLevel = slog.LevelInfo
case "WARN", "warn":
logLevel = slog.LevelWarn
case "ERROR", "error":
logLevel = slog.LevelError
}
}
logger = slog.New(
slog.NewTextHandler(
os.Stderr, &slog.HandlerOptions{
Level: logLevel,
},
),
)
slog.SetDefault(logger)
}
func initRootDir() {
var err error
rootDir, ok := os.LookupEnv("YUTU_ROOT")
if !ok {
rootDir, err = os.Getwd()
if err != nil {
rootDir = "/"
if runtime.GOOS == "windows" {
rootDir = os.Getenv("SystemDrive") + `\`
}
slog.Debug(getWdFailed, "error", err, "fallback", rootDir)
}
}
RootDir = &rootDir
Root, err = os.OpenRoot(*RootDir)
if err != nil {
slog.Error(openRootFailed, "dir", *RootDir, "error", err)
panic(err)
}
slog.Debug("Root directory set", "dir", *RootDir)
}
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package member
import (
"errors"
"io"
"math"
"github.com/eat-pray-ai/yutu/pkg"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/eat-pray-ai/yutu/pkg/utils"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetMember = errors.New("failed to get member")
)
type Member struct {
*common.Fields
MemberChannelId string `yaml:"member_channel_id" json:"member_channel_id"`
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 IMember[T any] interface {
List(io.Writer) error
Get() ([]*T, error)
}
type Option func(*Member)
func NewMember(opts ...Option) IMember[youtube.Member] {
m := &Member{Fields: &common.Fields{}}
for _, opt := range opts {
opt(m)
}
return m
}
func (m *Member) Get() ([]*youtube.Member, error) {
m.EnsureService()
call := m.Service.Members.List(m.Parts)
if m.MemberChannelId != "" {
call = call.FilterByMemberChannelId(m.MemberChannelId)
}
if m.HasAccessToLevel != "" {
call = call.HasAccessToLevel(m.HasAccessToLevel)
}
if m.Mode != "" {
call = call.Mode(m.Mode)
}
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(writer io.Writer) error {
members, err := m.Get()
if err != nil && members == nil {
return err
}
switch m.Output {
case "json":
utils.PrintJSON(members, m.Jsonpath, writer)
case "yaml":
utils.PrintYAML(members, m.Jsonpath, writer)
case "table":
tb := table.NewWriter()
defer tb.Render()
tb.SetOutputMirror(writer)
tb.SetStyle(pkg.TableStyle)
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
}
}
var (
WithParts = common.WithParts[*Member]
WithOutput = common.WithOutput[*Member]
WithJsonpath = common.WithJsonpath[*Member]
WithService = common.WithService[*Member]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package membershipsLevel
import (
"errors"
"io"
"github.com/eat-pray-ai/yutu/pkg"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/eat-pray-ai/yutu/pkg/utils"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetMembershipsLevel = errors.New("failed to get memberships level")
)
type MembershipsLevel struct {
*common.Fields
}
type IMembershipsLevel[T any] interface {
List(io.Writer) error
Get() ([]*T, error)
}
type Option func(*MembershipsLevel)
func NewMembershipsLevel(opts ...Option) IMembershipsLevel[youtube.MembershipsLevel] {
m := &MembershipsLevel{Fields: &common.Fields{}}
for _, opt := range opts {
opt(m)
}
return m
}
func (m *MembershipsLevel) GetFields() *common.Fields {
return m.Fields
}
func (m *MembershipsLevel) Get() ([]*youtube.MembershipsLevel, error) {
m.EnsureService()
call := m.Service.MembershipsLevels.List(m.Parts)
res, err := call.Do()
if err != nil {
return nil, errors.Join(errGetMembershipsLevel, err)
}
return res.Items, nil
}
func (m *MembershipsLevel) List(writer io.Writer) error {
membershipsLevels, err := m.Get()
if err != nil {
return err
}
switch m.Output {
case "json":
utils.PrintJSON(membershipsLevels, m.Jsonpath, writer)
case "yaml":
utils.PrintYAML(membershipsLevels, m.Jsonpath, writer)
case "table":
tb := table.NewWriter()
defer tb.Render()
tb.SetOutputMirror(writer)
tb.SetStyle(pkg.TableStyle)
tb.AppendHeader(table.Row{"ID", "Display Name"})
for _, ml := range membershipsLevels {
tb.AppendRow(table.Row{ml.Id, ml.Snippet.LevelDetails.DisplayName})
}
}
return nil
}
var (
WithParts = common.WithParts[*MembershipsLevel]
WithOutput = common.WithOutput[*MembershipsLevel]
WithJsonpath = common.WithJsonpath[*MembershipsLevel]
WithService = common.WithService[*MembershipsLevel]
)
// 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/common"
"github.com/eat-pray-ai/yutu/pkg/utils"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetPlaylist = errors.New("failed to get playlist")
errInsertPlaylist = errors.New("failed to insert playlist")
errUpdatePlaylist = errors.New("failed to update playlist")
errDeletePlaylist = errors.New("failed to delete playlist")
)
type Playlist struct {
*common.Fields
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 IPlaylist[T any] interface {
List(io.Writer) error
Insert(io.Writer) error
Update(io.Writer) error
Delete(io.Writer) error
Get() ([]*T, error)
}
type Option func(*Playlist)
func NewPlaylist(opts ...Option) IPlaylist[youtube.Playlist] {
p := &Playlist{Fields: &common.Fields{}}
for _, opt := range opts {
opt(p)
}
return p
}
func (p *Playlist) Get() ([]*youtube.Playlist, error) {
p.EnsureService()
call := p.Service.Playlists.List(p.Parts)
if len(p.Ids) > 0 {
call = call.Id(p.Ids...)
}
if p.ChannelId != "" {
call = call.ChannelId(p.ChannelId)
}
if p.Hl != "" {
call = call.Hl(p.Hl)
}
if p.Mine != nil {
call = call.Mine(*p.Mine)
}
if p.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(p.OnBehalfOfContentOwner)
}
if p.OnBehalfOfContentOwnerChannel != "" {
call = call.OnBehalfOfContentOwnerChannel(p.OnBehalfOfContentOwnerChannel)
}
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(writer io.Writer) error {
playlists, err := p.Get()
if err != nil && playlists == nil {
return err
}
switch p.Output {
case "json":
utils.PrintJSON(playlists, p.Jsonpath, writer)
case "yaml":
utils.PrintYAML(playlists, p.Jsonpath, writer)
case "table":
tb := table.NewWriter()
defer tb.Render()
tb.SetOutputMirror(writer)
tb.SetStyle(pkg.TableStyle)
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(writer io.Writer) error {
p.EnsureService()
upload := &youtube.Playlist{
Snippet: &youtube.PlaylistSnippet{
Title: p.Title,
Description: p.Description,
Tags: p.Tags,
DefaultLanguage: p.Language,
ChannelId: p.ChannelId,
},
Status: &youtube.PlaylistStatus{
PrivacyStatus: p.Privacy,
},
}
call := p.Service.Playlists.Insert([]string{"snippet", "status"}, upload)
if p.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(p.OnBehalfOfContentOwner)
}
if p.OnBehalfOfContentOwnerChannel != "" {
call = call.OnBehalfOfContentOwnerChannel(p.OnBehalfOfContentOwnerChannel)
}
res, err := call.Do()
if err != nil {
return errors.Join(errInsertPlaylist, err)
}
switch p.Output {
case "json":
utils.PrintJSON(res, p.Jsonpath, writer)
case "yaml":
utils.PrintYAML(res, p.Jsonpath, writer)
case "silent":
default:
_, _ = fmt.Fprintf(writer, "Playlist inserted: %s\n", res.Id)
}
return nil
}
func (p *Playlist) Update(writer io.Writer) error {
p.EnsureService()
playlists, err := p.Get()
if err != nil {
return errors.Join(errUpdatePlaylist, err)
}
if len(playlists) == 0 {
return errGetPlaylist
}
playlist := playlists[0]
if p.Title != "" {
playlist.Snippet.Title = p.Title
}
if p.Description != "" {
playlist.Snippet.Description = p.Description
}
if p.Tags != nil {
playlist.Snippet.Tags = p.Tags
}
if p.Language != "" {
playlist.Snippet.DefaultLanguage = p.Language
}
if p.Privacy != "" {
playlist.Status.PrivacyStatus = p.Privacy
}
call := p.Service.Playlists.Update([]string{"snippet", "status"}, playlist)
if p.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(p.OnBehalfOfContentOwner)
}
res, err := call.Do()
if err != nil {
return errors.Join(errUpdatePlaylist, err)
}
switch p.Output {
case "json":
utils.PrintJSON(res, p.Jsonpath, writer)
case "yaml":
utils.PrintYAML(res, p.Jsonpath, writer)
case "silent":
default:
_, _ = fmt.Fprintf(writer, "Playlist updated: %s\n", res.Id)
}
return nil
}
func (p *Playlist) Delete(writer io.Writer) error {
p.EnsureService()
for _, id := range p.Ids {
call := p.Service.Playlists.Delete(id)
if p.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(p.OnBehalfOfContentOwner)
}
err := call.Do()
if err != nil {
return errors.Join(errDeletePlaylist, err)
}
_, _ = fmt.Fprintf(writer, "Playlist %s deleted", id)
}
return nil
}
func 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
}
}
var (
WithParts = common.WithParts[*Playlist]
WithOutput = common.WithOutput[*Playlist]
WithJsonpath = common.WithJsonpath[*Playlist]
WithService = common.WithService[*Playlist]
)
// 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/common"
"github.com/eat-pray-ai/yutu/pkg/utils"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetPlaylistImage = errors.New("failed to get playlist image")
errInsertPlaylistImage = errors.New("failed to insert playlist image")
errUpdatePlaylistImage = errors.New("failed to update playlist image")
errDeletePlaylistImage = errors.New("failed to delete playlist image")
)
type PlaylistImage struct {
*common.Fields
Ids []string `yaml:"ids" json:"ids"`
Height int64 `yaml:"height" json:"height"`
PlaylistId string `yaml:"playlist_id" json:"playlist_id"`
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 IPlaylistImage[T any] interface {
Get() ([]*T, error)
List(io.Writer) error
Insert(io.Writer) error
Update(io.Writer) error
Delete(io.Writer) error
}
type Option func(*PlaylistImage)
func NewPlaylistImage(opts ...Option) IPlaylistImage[youtube.PlaylistImage] {
pi := &PlaylistImage{Fields: &common.Fields{}}
for _, opt := range opts {
opt(pi)
}
return pi
}
func (pi *PlaylistImage) Get() ([]*youtube.PlaylistImage, error) {
pi.EnsureService()
call := pi.Service.PlaylistImages.List()
call = call.Part(pi.Parts...)
if pi.Parent != "" {
call = call.Parent(pi.Parent)
}
if pi.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(pi.OnBehalfOfContentOwner)
}
if pi.OnBehalfOfContentOwnerChannel != "" {
call = call.OnBehalfOfContentOwnerChannel(pi.OnBehalfOfContentOwnerChannel)
}
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(writer io.Writer) error {
playlistImages, err := pi.Get()
if err != nil && playlistImages == nil {
return err
}
switch pi.Output {
case "json":
utils.PrintJSON(playlistImages, pi.Jsonpath, writer)
case "yaml":
utils.PrintYAML(playlistImages, pi.Jsonpath, writer)
case "table":
tb := table.NewWriter()
defer tb.Render()
tb.SetOutputMirror(writer)
tb.SetStyle(pkg.TableStyle)
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(writer io.Writer) error {
pi.EnsureService()
file, err := pkg.Root.Open(pi.File)
if err != nil {
return errors.Join(errInsertPlaylistImage, err)
}
defer func(file *os.File) {
_ = file.Close()
}(file)
playlistImage := &youtube.PlaylistImage{
Kind: "youtube#playlistImages",
Snippet: &youtube.PlaylistImageSnippet{
PlaylistId: pi.PlaylistId,
Type: pi.Type,
Height: pi.Height,
Width: pi.Width,
},
}
call := pi.Service.PlaylistImages.Insert(playlistImage)
if pi.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(pi.OnBehalfOfContentOwner)
}
if pi.OnBehalfOfContentOwnerChannel != "" {
call = call.OnBehalfOfContentOwnerChannel(pi.OnBehalfOfContentOwnerChannel)
}
call = call.Media(file)
call = call.Part("kind", "snippet")
res, err := call.Do()
if err != nil {
return errors.Join(errInsertPlaylistImage, err)
}
switch pi.Output {
case "json":
utils.PrintJSON(res, pi.Jsonpath, writer)
case "yaml":
utils.PrintYAML(res, pi.Jsonpath, writer)
case "silent":
default:
_, _ = fmt.Fprintf(writer, "PlaylistImage inserted: %s\n", res.Id)
}
return nil
}
func (pi *PlaylistImage) Update(writer io.Writer) error {
pi.EnsureService()
pi.Parts = []string{"id", "kind", "snippet"}
playlistImages, err := pi.Get()
if err != nil {
return errors.Join(errUpdatePlaylistImage, err)
}
if len(playlistImages) == 0 {
return errGetPlaylistImage
}
playlistImage := playlistImages[0]
if pi.PlaylistId != "" {
playlistImage.Snippet.PlaylistId = pi.PlaylistId
}
if pi.Type != "" {
playlistImage.Snippet.Type = pi.Type
}
if pi.Height != 0 {
playlistImage.Snippet.Height = pi.Height
}
if pi.Width != 0 {
playlistImage.Snippet.Width = pi.Width
}
call := pi.Service.PlaylistImages.Update(playlistImage)
if pi.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(pi.OnBehalfOfContentOwner)
}
if pi.File != "" {
file, err := pkg.Root.Open(pi.File)
if err != nil {
return errors.Join(errUpdatePlaylistImage, err)
}
defer func(file *os.File) {
_ = file.Close()
}(file)
call = call.Media(file)
}
call = call.Part("id", "kind", "snippet")
res, err := call.Do()
if err != nil {
return errors.Join(errUpdatePlaylistImage, err)
}
switch pi.Output {
case "json":
utils.PrintJSON(res, pi.Jsonpath, writer)
case "yaml":
utils.PrintYAML(res, pi.Jsonpath, writer)
case "silent":
default:
_, _ = fmt.Fprintf(writer, "PlaylistImage updated: %s\n", res.Id)
}
return nil
}
func (pi *PlaylistImage) Delete(writer io.Writer) error {
pi.EnsureService()
for _, id := range pi.Ids {
call := pi.Service.PlaylistImages.Delete()
call = call.Id(id)
if pi.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(pi.OnBehalfOfContentOwner)
}
err := call.Do()
if err != nil {
return errors.Join(errDeletePlaylistImage, err)
}
_, _ = fmt.Fprintf(writer, "PlaylistImage %s deleted\n", id)
}
return nil
}
func 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
}
}
var (
WithParts = common.WithParts[*PlaylistImage]
WithOutput = common.WithOutput[*PlaylistImage]
WithJsonpath = common.WithJsonpath[*PlaylistImage]
WithService = common.WithService[*PlaylistImage]
)
// 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/common"
"github.com/eat-pray-ai/yutu/pkg/utils"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetPlaylistItem = errors.New("failed to get playlist item")
errUpdatePlaylistItem = errors.New("failed to update playlist item")
errInsertPlaylistItem = errors.New("failed to insert playlist item")
errDeletePlaylistItem = errors.New("failed to delete playlist item")
)
type PlaylistItem struct {
*common.Fields
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 IPlaylistItem[T any] interface {
List(io.Writer) error
Insert(io.Writer) error
Update(io.Writer) error
Delete(io.Writer) error
Get() ([]*T, error)
}
type Option func(*PlaylistItem)
func NewPlaylistItem(opts ...Option) IPlaylistItem[youtube.PlaylistItem] {
p := &PlaylistItem{Fields: &common.Fields{}}
for _, opt := range opts {
opt(p)
}
return p
}
func (pi *PlaylistItem) Get() ([]*youtube.PlaylistItem, error) {
pi.EnsureService()
call := pi.Service.PlaylistItems.List(pi.Parts)
if len(pi.Ids) > 0 {
call = call.Id(pi.Ids...)
}
if pi.PlaylistId != "" {
call = call.PlaylistId(pi.PlaylistId)
}
if pi.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(pi.OnBehalfOfContentOwner)
}
if pi.VideoId != "" {
call = call.VideoId(pi.VideoId)
}
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(writer io.Writer) error {
playlistItems, err := pi.Get()
if err != nil && playlistItems == nil {
return err
}
switch pi.Output {
case "json":
utils.PrintJSON(playlistItems, pi.Jsonpath, writer)
case "yaml":
utils.PrintYAML(playlistItems, pi.Jsonpath, writer)
case "table":
tb := table.NewWriter()
defer tb.Render()
tb.SetOutputMirror(writer)
tb.SetStyle(pkg.TableStyle)
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(writer io.Writer) error {
pi.EnsureService()
var resourceId *youtube.ResourceId
switch pi.Kind {
case "video":
resourceId = &youtube.ResourceId{
Kind: "youtube#video",
VideoId: pi.KVideoId,
}
case "channel":
resourceId = &youtube.ResourceId{
Kind: "youtube#channel",
ChannelId: pi.KChannelId,
}
case "playlist":
resourceId = &youtube.ResourceId{
Kind: "youtube#playlist",
PlaylistId: pi.KPlaylistId,
}
}
playlistItem := &youtube.PlaylistItem{
Snippet: &youtube.PlaylistItemSnippet{
Title: pi.Title,
Description: pi.Description,
ResourceId: resourceId,
PlaylistId: pi.PlaylistId,
ChannelId: pi.ChannelId,
},
Status: &youtube.PlaylistItemStatus{
PrivacyStatus: pi.Privacy,
},
}
call := pi.Service.PlaylistItems.Insert(
[]string{"snippet", "status"}, playlistItem,
)
if pi.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(pi.OnBehalfOfContentOwner)
}
res, err := call.Do()
if err != nil {
return errors.Join(errInsertPlaylistItem, err)
}
switch pi.Output {
case "json":
utils.PrintJSON(res, pi.Jsonpath, writer)
case "yaml":
utils.PrintYAML(res, pi.Jsonpath, writer)
case "silent":
default:
_, _ = fmt.Fprintf(writer, "Playlist Item inserted: %s\n", res.Id)
}
return nil
}
func (pi *PlaylistItem) Update(writer io.Writer) error {
pi.EnsureService()
pi.Parts = []string{"id", "snippet", "status"}
playlistItems, err := pi.Get()
if err != nil {
return errors.Join(errUpdatePlaylistItem, err)
}
if len(playlistItems) == 0 {
return errGetPlaylistItem
}
playlistItem := playlistItems[0]
if pi.Title != "" {
playlistItem.Snippet.Title = pi.Title
}
if pi.Description != "" {
playlistItem.Snippet.Description = pi.Description
}
if pi.Privacy != "" {
playlistItem.Status.PrivacyStatus = pi.Privacy
}
call := pi.Service.PlaylistItems.Update(
[]string{"snippet", "status"}, playlistItem,
)
if pi.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(pi.OnBehalfOfContentOwner)
}
res, err := call.Do()
if err != nil {
return errors.Join(errUpdatePlaylistItem, err)
}
switch pi.Output {
case "json":
utils.PrintJSON(res, pi.Jsonpath, writer)
case "yaml":
utils.PrintYAML(res, pi.Jsonpath, writer)
case "silent":
default:
_, _ = fmt.Fprintf(writer, "Playlist Item updated: %s\n", res.Id)
}
return nil
}
func (pi *PlaylistItem) Delete(writer io.Writer) error {
pi.EnsureService()
for _, id := range pi.Ids {
call := pi.Service.PlaylistItems.Delete(id)
if pi.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(pi.OnBehalfOfContentOwner)
}
err := call.Do()
if err != nil {
return errors.Join(errDeletePlaylistItem, err)
}
_, _ = fmt.Fprintf(writer, "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
}
}
var (
WithParts = common.WithParts[*PlaylistItem]
WithOutput = common.WithOutput[*PlaylistItem]
WithJsonpath = common.WithJsonpath[*PlaylistItem]
WithService = common.WithService[*PlaylistItem]
)
// 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/common"
"github.com/eat-pray-ai/yutu/pkg/utils"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetSearch = errors.New("failed to get search")
)
type Search struct {
*common.Fields
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 ISearch[T any] interface {
Get() ([]*T, error)
List(io.Writer) error
}
type Option func(*Search)
func NewSearch(opts ...Option) ISearch[youtube.SearchResult] {
s := &Search{Fields: &common.Fields{}}
for _, opt := range opts {
opt(s)
}
return s
}
func (s *Search) Get() ([]*youtube.SearchResult, error) {
s.EnsureService()
call := s.Service.Search.List(s.Parts)
if s.ChannelId != "" {
call = call.ChannelId(s.ChannelId)
}
if s.ChannelType != "" {
call = call.ChannelType(s.ChannelType)
}
if s.EventType != "" {
call = call.EventType(s.EventType)
}
if s.ForContentOwner != nil {
call = call.ForContentOwner(*s.ForContentOwner)
}
if s.ForDeveloper != nil {
call = call.ForDeveloper(*s.ForDeveloper)
}
if s.ForMine != nil {
call = call.ForMine(*s.ForMine)
}
if s.Location != "" {
call = call.Location(s.Location)
}
if s.LocationRadius != "" {
call = call.LocationRadius(s.LocationRadius)
}
if s.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(s.OnBehalfOfContentOwner)
}
if s.Order != "" {
call = call.Order(s.Order)
}
if s.PublishedAfter != "" {
call = call.PublishedAfter(s.PublishedAfter)
}
if s.PublishedBefore != "" {
call = call.PublishedBefore(s.PublishedBefore)
}
if s.Q != "" {
call = call.Q(s.Q)
}
if s.RegionCode != "" {
call = call.RegionCode(s.RegionCode)
}
if s.RelevanceLanguage != "" {
call = call.RelevanceLanguage(s.RelevanceLanguage)
}
if s.SafeSearch != "" {
call = call.SafeSearch(s.SafeSearch)
}
if s.TopicId != "" {
call = call.TopicId(s.TopicId)
}
if len(s.Types) > 0 {
call = call.Type(s.Types...)
}
if s.VideoCaption != "" {
call = call.VideoCaption(s.VideoCaption)
}
if s.VideoCategoryId != "" {
call = call.VideoCategoryId(s.VideoCategoryId)
}
if s.VideoDefinition != "" {
call = call.VideoDefinition(s.VideoDefinition)
}
if s.VideoDimension != "" {
call = call.VideoDimension(s.VideoDimension)
}
if s.VideoDuration != "" {
call = call.VideoDuration(s.VideoDuration)
}
if s.VideoEmbeddable != "" {
call = call.VideoEmbeddable(s.VideoEmbeddable)
}
if s.VideoLicense != "" {
call = call.VideoLicense(s.VideoLicense)
}
if s.VideoPaidProductPlacement != "" {
call = call.VideoPaidProductPlacement(s.VideoPaidProductPlacement)
}
if s.VideoSyndicated != "" {
call = call.VideoSyndicated(s.VideoSyndicated)
}
if s.VideoType != "" {
call = call.VideoType(s.VideoType)
}
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(writer io.Writer) error {
results, err := s.Get()
if err != nil && results == nil {
return err
}
switch s.Output {
case "json":
utils.PrintJSON(results, s.Jsonpath, writer)
case "yaml":
utils.PrintYAML(results, s.Jsonpath, writer)
case "table":
tb := table.NewWriter()
defer tb.Render()
tb.SetOutputMirror(writer)
tb.SetStyle(pkg.TableStyle)
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
}
}
var (
WithParts = common.WithParts[*Search]
WithOutput = common.WithOutput[*Search]
WithJsonpath = common.WithJsonpath[*Search]
WithService = common.WithService[*Search]
)
// 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/common"
"github.com/eat-pray-ai/yutu/pkg/utils"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetSubscription = errors.New("failed to get subscription")
errDeleteSubscription = errors.New("failed to delete subscription")
errInsertSubscription = errors.New("failed to insert subscription")
)
type Subscription struct {
*common.Fields
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 ISubscription[T any] interface {
Get() ([]*T, error)
List(io.Writer) error
Insert(io.Writer) error
Delete(io.Writer) error
}
type Option func(*Subscription)
func NewSubscription(opts ...Option) ISubscription[youtube.Subscription] {
s := &Subscription{Fields: &common.Fields{}}
for _, opt := range opts {
opt(s)
}
return s
}
func (s *Subscription) Get() ([]*youtube.Subscription, error) {
s.EnsureService()
call := s.Service.Subscriptions.List(s.Parts)
if len(s.Ids) > 0 {
call = call.Id(s.Ids...)
}
if s.ChannelId != "" {
call = call.ChannelId(s.ChannelId)
}
if s.ForChannelId != "" {
call = call.ForChannelId(s.ForChannelId)
}
if s.Mine != nil {
call = call.Mine(*s.Mine)
}
if s.MyRecentSubscribers != nil {
call = call.MyRecentSubscribers(*s.MyRecentSubscribers)
}
if s.MySubscribers != nil {
call = call.MySubscribers(*s.MySubscribers)
}
if s.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(s.OnBehalfOfContentOwner)
}
if s.OnBehalfOfContentOwnerChannel != "" {
call = call.OnBehalfOfContentOwnerChannel(s.OnBehalfOfContentOwnerChannel)
}
if s.Order != "" {
call = call.Order(s.Order)
}
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(writer io.Writer) error {
subscriptions, err := s.Get()
if err != nil && subscriptions == nil {
return err
}
switch s.Output {
case "json":
utils.PrintJSON(subscriptions, s.Jsonpath, writer)
case "yaml":
utils.PrintYAML(subscriptions, s.Jsonpath, writer)
case "table":
tb := table.NewWriter()
defer tb.Render()
tb.SetOutputMirror(writer)
tb.SetStyle(pkg.TableStyle)
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(writer io.Writer) error {
s.EnsureService()
subscription := &youtube.Subscription{
Snippet: &youtube.SubscriptionSnippet{
ChannelId: s.SubscriberChannelId,
Description: s.Description,
ResourceId: &youtube.ResourceId{
ChannelId: s.ChannelId,
},
Title: s.Title,
},
}
call := s.Service.Subscriptions.Insert([]string{"snippet"}, subscription)
res, err := call.Do()
if err != nil {
return errors.Join(errInsertSubscription, err)
}
switch s.Output {
case "json":
utils.PrintJSON(res, s.Jsonpath, writer)
case "yaml":
utils.PrintYAML(res, s.Jsonpath, writer)
default:
_, _ = fmt.Fprintf(writer, "Subscription inserted: %s\n", res.Id)
}
return nil
}
func (s *Subscription) Delete(writer io.Writer) error {
s.EnsureService()
for _, id := range s.Ids {
call := s.Service.Subscriptions.Delete(id)
err := call.Do()
if err != nil {
return errors.Join(errDeleteSubscription, err)
}
_, _ = fmt.Fprintf(writer, "Subscription %s deleted", id)
}
return nil
}
func 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
}
}
var (
WithParts = common.WithParts[*Subscription]
WithOutput = common.WithOutput[*Subscription]
WithJsonpath = common.WithJsonpath[*Subscription]
WithService = common.WithService[*Subscription]
)
// 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/common"
"github.com/eat-pray-ai/yutu/pkg/utils"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetSuperChatEvent = errors.New("failed to get super chat event")
)
type SuperChatEvent struct {
*common.Fields
Hl string `yaml:"hl" json:"hl"`
MaxResults int64 `yaml:"max_results" json:"max_results"`
}
type ISuperChatEvent[T any] interface {
Get() ([]*T, error)
List(io.Writer) error
}
type Option func(*SuperChatEvent)
func NewSuperChatEvent(opts ...Option) ISuperChatEvent[youtube.SuperChatEvent] {
s := &SuperChatEvent{Fields: &common.Fields{}}
for _, opt := range opts {
opt(s)
}
return s
}
func (s *SuperChatEvent) Get() ([]*youtube.SuperChatEvent, error) {
s.EnsureService()
call := s.Service.SuperChatEvents.List(s.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(writer io.Writer) error {
events, err := s.Get()
if err != nil && events == nil {
return err
}
switch s.Output {
case "json":
utils.PrintJSON(events, s.Jsonpath, writer)
case "yaml":
utils.PrintYAML(events, s.Jsonpath, writer)
case "table":
tb := table.NewWriter()
defer tb.Render()
tb.SetOutputMirror(writer)
tb.SetStyle(pkg.TableStyle)
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
}
}
var (
WithParts = common.WithParts[*SuperChatEvent]
WithOutput = common.WithOutput[*SuperChatEvent]
WithJsonpath = common.WithJsonpath[*SuperChatEvent]
WithService = common.WithService[*SuperChatEvent]
)
// 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/common"
"github.com/eat-pray-ai/yutu/pkg/utils"
)
var (
errSetThumbnail = errors.New("failed to set thumbnail")
)
type Thumbnail struct {
*common.Fields
File string `yaml:"file" json:"file"`
VideoId string `yaml:"video_id" json:"video_id"`
}
type IThumbnail interface {
Set(io.Writer) error
}
type Option func(*Thumbnail)
func NewThumbnail(opts ...Option) IThumbnail {
t := &Thumbnail{Fields: &common.Fields{}}
for _, opt := range opts {
opt(t)
}
return t
}
func (t *Thumbnail) Set(writer io.Writer) error {
t.EnsureService()
file, err := pkg.Root.Open(t.File)
if err != nil {
return errors.Join(errSetThumbnail, err)
}
call := t.Service.Thumbnails.Set(t.VideoId).Media(file)
res, err := call.Do()
if err != nil {
return errors.Join(errSetThumbnail, err)
}
switch t.Output {
case "json":
utils.PrintJSON(res, t.Jsonpath, writer)
case "yaml":
utils.PrintYAML(res, t.Jsonpath, 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
}
}
var (
WithOutput = common.WithOutput[*Thumbnail]
WithJsonpath = common.WithJsonpath[*Thumbnail]
WithService = common.WithService[*Thumbnail]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package utils
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"github.com/ohler55/ojg/jp"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"gopkg.in/yaml.v3"
)
func PrintJSON(data any, jsonpath string, writer io.Writer) {
j, err := jp.ParseString(jsonpath)
if err != nil && jsonpath != "" {
_, _ = fmt.Fprintln(writer, "Invalid JSONPath:", jsonpath)
return
} else if jsonpath != "" {
data = j.Get(data)
}
marshalled, _ := json.MarshalIndent(data, "", " ")
_, _ = fmt.Fprintln(writer, string(marshalled))
}
func PrintYAML(data any, jsonpath string, writer io.Writer) {
j, err := jp.ParseString(jsonpath)
if err != nil && jsonpath != "" {
_, _ = fmt.Fprintln(writer, "Invalid JSONPath:", jsonpath)
return
} else if jsonpath != "" {
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 StrToBoolPtr(b *string) *bool {
if b == nil || *b == "" || strings.ToLower(strings.TrimSpace(*b)) == "null" {
return nil
}
val := *b == "true"
return &val
}
func BoolToStrPtr(b *bool) *string {
if b == nil {
return new("")
}
return new(strconv.FormatBool(*b))
}
func ResetBool(m map[string]**bool, flagSet *pflag.FlagSet) {
for k := range m {
flag := flagSet.Lookup(k)
if flag != nil && !flag.Changed {
*m[k] = nil
}
}
}
func ExtractHl(uri string) string {
pattern := `i18n://(?:language|region)/([^/]+)`
matches := regexp.MustCompile(pattern).FindStringSubmatch(uri)
if len(matches) > 1 {
return matches[1]
}
return ""
}
func HandleCmdError(err error, cmd *cobra.Command) {
if err != nil {
_ = cmd.Help()
cmd.PrintErrf("Error: %v\n", err)
}
}
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package video
import (
"errors"
"fmt"
"io"
"math"
"os"
"slices"
"github.com/eat-pray-ai/yutu/pkg"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/eat-pray-ai/yutu/pkg/playlistItem"
"github.com/eat-pray-ai/yutu/pkg/thumbnail"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/eat-pray-ai/yutu/pkg/utils"
"google.golang.org/api/youtube/v3"
)
var (
errGetVideo = errors.New("failed to get video")
errInsertVideo = errors.New("failed to insert video")
errUpdateVideo = errors.New("failed to update video")
errRating = errors.New("failed to rate video")
errGetRating = errors.New("failed to get rating")
errDeleteVideo = errors.New("failed to delete video")
errReportAbuse = errors.New("failed to report abuse")
)
type Video struct {
*common.Fields
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 IVideo[T any] interface {
List(io.Writer) error
Insert(io.Writer) error
Update(io.Writer) error
Rate(io.Writer) error
GetRating(io.Writer) error
Delete(io.Writer) error
ReportAbuse(io.Writer) error
Get() ([]*T, error)
}
type Option func(*Video)
func NewVideo(opts ...Option) IVideo[youtube.Video] {
v := &Video{Fields: &common.Fields{}}
for _, opt := range opts {
opt(v)
}
return v
}
func (v *Video) Get() ([]*youtube.Video, error) {
v.EnsureService()
call := v.Service.Videos.List(v.Parts)
if len(v.Ids) > 0 {
call = call.Id(v.Ids...)
}
if v.Chart != "" {
call = call.Chart(v.Chart)
}
if v.Hl != "" {
call = call.Hl(v.Hl)
}
if v.Locale != "" {
call = call.Locale(v.Locale)
}
if v.CategoryId != "" {
call = call.VideoCategoryId(v.CategoryId)
}
if v.Rating != "" {
call = call.MyRating(v.Rating)
}
if v.RegionCode != "" {
call = call.RegionCode(v.RegionCode)
}
if v.MaxHeight != 0 {
call = call.MaxHeight(v.MaxHeight)
}
if v.MaxWidth != 0 {
call = call.MaxWidth(v.MaxWidth)
}
if v.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(v.OnBehalfOfContentOwner)
}
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(writer io.Writer) error {
videos, err := v.Get()
if err != nil && videos == nil {
return err
}
switch v.Output {
case "json":
utils.PrintJSON(videos, v.Jsonpath, writer)
case "yaml":
utils.PrintYAML(videos, v.Jsonpath, writer)
case "table":
tb := table.NewWriter()
defer tb.Render()
tb.SetOutputMirror(writer)
tb.SetStyle(pkg.TableStyle)
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(writer io.Writer) error {
v.EnsureService()
file, err := pkg.Root.Open(v.File)
if err != nil {
return errors.Join(errInsertVideo, err)
}
defer func(file *os.File) {
_ = file.Close()
}(file)
if !slices.Contains(v.Tags, "yutu🐰") {
v.Tags = append(v.Tags, "yutu🐰")
}
if v.Title == "" {
v.Title = utils.GetFileName(v.File)
}
video := &youtube.Video{
Snippet: &youtube.VideoSnippet{
Title: v.Title,
Description: v.Description,
Tags: v.Tags,
CategoryId: v.CategoryId,
ChannelId: v.ChannelId,
DefaultLanguage: v.Language,
DefaultAudioLanguage: v.Language,
},
Status: &youtube.VideoStatus{
License: v.License,
PublishAt: v.PublishAt,
PrivacyStatus: v.Privacy,
ForceSendFields: []string{"SelfDeclaredMadeForKids"},
},
}
if v.Embeddable != nil {
video.Status.Embeddable = *v.Embeddable
}
if v.ForKids != nil {
video.Status.SelfDeclaredMadeForKids = *v.ForKids
}
if v.PublicStatsViewable != nil {
video.Status.PublicStatsViewable = *v.PublicStatsViewable
}
call := v.Service.Videos.Insert([]string{"snippet,status"}, video)
if v.AutoLevels != nil {
call = call.AutoLevels(*v.AutoLevels)
}
if v.NotifySubscribers != nil {
call = call.NotifySubscribers(*v.NotifySubscribers)
}
if v.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(v.OnBehalfOfContentOwner)
}
if v.OnBehalfOfContentOwnerChannel != "" {
call = call.OnBehalfOfContentOwnerChannel(v.OnBehalfOfContentOwnerChannel)
}
if v.Stabilize != nil {
call = call.Stabilize(*v.Stabilize)
}
res, err := call.Media(file).Do()
if err != nil {
return errors.Join(errInsertVideo, err)
}
if v.Thumbnail != "" {
t := thumbnail.NewThumbnail(
thumbnail.WithVideoId(res.Id),
thumbnail.WithFile(v.Thumbnail),
thumbnail.WithService(v.Service),
thumbnail.WithOutput("silent"),
thumbnail.WithJsonpath(""),
)
_ = t.Set(nil)
}
if v.PlaylistId != "" {
pi := playlistItem.NewPlaylistItem(
playlistItem.WithTitle(res.Snippet.Title),
playlistItem.WithDescription(res.Snippet.Description),
playlistItem.WithKind("video"),
playlistItem.WithKVideoId(res.Id),
playlistItem.WithPlaylistId(v.PlaylistId),
playlistItem.WithChannelId(res.Snippet.ChannelId),
playlistItem.WithPrivacy(res.Status.PrivacyStatus),
playlistItem.WithService(v.Service),
playlistItem.WithOutput("silent"),
playlistItem.WithJsonpath(""),
)
_ = pi.Insert(writer)
}
switch v.Output {
case "json":
utils.PrintJSON(res, v.Jsonpath, writer)
case "yaml":
utils.PrintYAML(res, v.Jsonpath, writer)
case "silent":
default:
_, _ = fmt.Fprintf(writer, "Video inserted: %s\n", res.Id)
}
return nil
}
func (v *Video) Update(writer io.Writer) error {
v.EnsureService()
v.Parts = []string{"id", "snippet", "status"}
videos, err := v.Get()
if err != nil {
return errors.Join(errUpdateVideo, err)
}
if len(videos) == 0 {
return errGetVideo
}
video := videos[0]
if v.Title != "" {
video.Snippet.Title = v.Title
}
if v.Description != "" {
video.Snippet.Description = v.Description
}
if v.Tags != nil {
if !slices.Contains(v.Tags, "yutu🐰") {
v.Tags = append(v.Tags, "yutu🐰")
}
video.Snippet.Tags = v.Tags
}
if v.Language != "" {
video.Snippet.DefaultLanguage = v.Language
video.Snippet.DefaultAudioLanguage = v.Language
}
if v.License != "" {
video.Status.License = v.License
}
if v.CategoryId != "" {
video.Snippet.CategoryId = v.CategoryId
}
if v.Privacy != "" {
video.Status.PrivacyStatus = v.Privacy
}
if v.Embeddable != nil {
video.Status.Embeddable = *v.Embeddable
}
call := v.Service.Videos.Update([]string{"snippet,status"}, video)
if v.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(v.OnBehalfOfContentOwner)
}
res, err := call.Do()
if err != nil {
return errors.Join(errUpdateVideo, err)
}
if v.Thumbnail != "" {
t := thumbnail.NewThumbnail(
thumbnail.WithVideoId(res.Id),
thumbnail.WithFile(v.Thumbnail),
thumbnail.WithService(v.Service),
thumbnail.WithOutput("silent"),
thumbnail.WithJsonpath(""),
)
_ = t.Set(nil)
}
if v.PlaylistId != "" {
pi := playlistItem.NewPlaylistItem(
playlistItem.WithTitle(res.Snippet.Title),
playlistItem.WithDescription(res.Snippet.Description),
playlistItem.WithKind("video"),
playlistItem.WithKVideoId(res.Id),
playlistItem.WithPlaylistId(v.PlaylistId),
playlistItem.WithChannelId(res.Snippet.ChannelId),
playlistItem.WithPrivacy(res.Status.PrivacyStatus),
playlistItem.WithService(v.Service),
playlistItem.WithOutput("silent"),
playlistItem.WithJsonpath(""),
)
_ = pi.Insert(writer)
}
switch v.Output {
case "json":
utils.PrintJSON(res, v.Jsonpath, writer)
case "yaml":
utils.PrintYAML(res, v.Jsonpath, writer)
case "silent":
default:
_, _ = fmt.Fprintf(writer, "Video updated: %s\n", res.Id)
}
return nil
}
func (v *Video) Rate(writer io.Writer) error {
v.EnsureService()
for _, id := range v.Ids {
call := v.Service.Videos.Rate(id, v.Rating)
err := call.Do()
if err != nil {
return errors.Join(errRating, err)
}
_, _ = fmt.Fprintf(writer, "Video %s rated %s\n", id, v.Rating)
}
return nil
}
func (v *Video) GetRating(writer io.Writer) error {
v.EnsureService()
call := v.Service.Videos.GetRating(v.Ids)
if v.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(v.OnBehalfOfContentOwner)
}
res, err := call.Do()
if err != nil {
return errors.Join(errGetRating, err)
}
switch v.Output {
case "json":
utils.PrintJSON(res.Items, v.Jsonpath, writer)
case "yaml":
utils.PrintYAML(res.Items, v.Jsonpath, writer)
default:
tb := table.NewWriter()
defer tb.Render()
tb.SetOutputMirror(writer)
tb.SetStyle(pkg.TableStyle)
tb.AppendHeader(table.Row{"ID", "Rating"})
for _, item := range res.Items {
tb.AppendRow(table.Row{item.VideoId, item.Rating})
}
}
return nil
}
func (v *Video) Delete(writer io.Writer) error {
v.EnsureService()
for _, id := range v.Ids {
call := v.Service.Videos.Delete(id)
if v.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(v.OnBehalfOfContentOwner)
}
err := call.Do()
if err != nil {
return errors.Join(errDeleteVideo, err)
}
_, _ = fmt.Fprintf(writer, "Video %s deleted", id)
}
return nil
}
func (v *Video) ReportAbuse(writer io.Writer) error {
v.EnsureService()
for _, id := range v.Ids {
videoAbuseReport := &youtube.VideoAbuseReport{
Comments: v.Comments,
Language: v.Language,
ReasonId: v.ReasonId,
SecondaryReasonId: v.SecondaryReasonId,
VideoId: id,
}
call := v.Service.Videos.ReportAbuse(videoAbuseReport)
if v.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(v.OnBehalfOfContentOwner)
}
err := call.Do()
if err != nil {
return errors.Join(errReportAbuse, err)
}
_, _ = fmt.Fprintf(writer, "Video %s reported for abuse", id)
}
return nil
}
func 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
}
}
var (
WithParts = common.WithParts[*Video]
WithOutput = common.WithOutput[*Video]
WithJsonpath = common.WithJsonpath[*Video]
WithService = common.WithService[*Video]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package videoAbuseReportReason
import (
"errors"
"io"
"github.com/eat-pray-ai/yutu/pkg"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/eat-pray-ai/yutu/pkg/utils"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetVideoAbuseReportReason = errors.New("failed to get video abuse report reason")
)
type VideoAbuseReportReason struct {
*common.Fields
Hl string `yaml:"hl" json:"hl"`
}
type IVideoAbuseReportReason[T any] interface {
Get() ([]*T, error)
List(io.Writer) error
}
type Option func(*VideoAbuseReportReason)
func NewVideoAbuseReportReason(opt ...Option) IVideoAbuseReportReason[youtube.VideoAbuseReportReason] {
va := &VideoAbuseReportReason{Fields: &common.Fields{}}
for _, o := range opt {
o(va)
}
return va
}
func (va *VideoAbuseReportReason) Get() (
[]*youtube.VideoAbuseReportReason, error,
) {
va.EnsureService()
call := va.Service.VideoAbuseReportReasons.List(va.Parts)
if va.Hl != "" {
call = call.Hl(va.Hl)
}
res, err := call.Do()
if err != nil {
return nil, errors.Join(errGetVideoAbuseReportReason, err)
}
return res.Items, nil
}
func (va *VideoAbuseReportReason) List(writer io.Writer) error {
videoAbuseReportReasons, err := va.Get()
if err != nil {
return err
}
switch va.Output {
case "json":
utils.PrintJSON(videoAbuseReportReasons, va.Jsonpath, writer)
case "yaml":
utils.PrintYAML(videoAbuseReportReasons, va.Jsonpath, writer)
case "table":
tb := table.NewWriter()
defer tb.Render()
tb.SetOutputMirror(writer)
tb.SetStyle(pkg.TableStyle)
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(va *VideoAbuseReportReason) {
va.Hl = hl
}
}
var (
WithParts = common.WithParts[*VideoAbuseReportReason]
WithOutput = common.WithOutput[*VideoAbuseReportReason]
WithJsonpath = common.WithJsonpath[*VideoAbuseReportReason]
WithService = common.WithService[*VideoAbuseReportReason]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package videoCategory
import (
"errors"
"io"
"github.com/eat-pray-ai/yutu/pkg"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/eat-pray-ai/yutu/pkg/utils"
"github.com/jedib0t/go-pretty/v6/table"
"google.golang.org/api/youtube/v3"
)
var (
errGetVideoCategory = errors.New("failed to get video categoryId")
)
type VideoCategory struct {
Ids []string `yaml:"ids" json:"ids"`
Hl string `yaml:"hl" json:"hl"`
RegionCode string `yaml:"region_code" json:"region_code"`
*common.Fields
}
type IVideoCategory[T any] interface {
Get() ([]*T, error)
List(io.Writer) error
}
type Option func(*VideoCategory)
func NewVideoCategory(opt ...Option) IVideoCategory[youtube.VideoCategory] {
vc := &VideoCategory{Fields: &common.Fields{}}
for _, o := range opt {
o(vc)
}
return vc
}
func (vc *VideoCategory) Get() ([]*youtube.VideoCategory, error) {
vc.EnsureService()
call := vc.Service.VideoCategories.List(vc.Parts)
if len(vc.Ids) > 0 {
call = call.Id(vc.Ids...)
}
if vc.Hl != "" {
call = call.Hl(vc.Hl)
}
if vc.RegionCode != "" {
call = call.RegionCode(vc.RegionCode)
}
res, err := call.Do()
if err != nil {
return nil, errors.Join(errGetVideoCategory, err)
}
return res.Items, nil
}
func (vc *VideoCategory) List(writer io.Writer) error {
videoCategories, err := vc.Get()
if err != nil {
return err
}
switch vc.Output {
case "json":
utils.PrintJSON(videoCategories, vc.Jsonpath, writer)
case "yaml":
utils.PrintYAML(videoCategories, vc.Jsonpath, writer)
case "table":
tb := table.NewWriter()
defer tb.Render()
tb.SetOutputMirror(writer)
tb.SetStyle(pkg.TableStyle)
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
}
}
var (
WithParts = common.WithParts[*VideoCategory]
WithOutput = common.WithOutput[*VideoCategory]
WithJsonpath = common.WithJsonpath[*VideoCategory]
WithService = common.WithService[*VideoCategory]
)
// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package watermark
import (
"errors"
"fmt"
"io"
"os"
"github.com/eat-pray-ai/yutu/pkg"
"github.com/eat-pray-ai/yutu/pkg/common"
"google.golang.org/api/youtube/v3"
)
var (
errSetWatermark = errors.New("failed to set watermark")
errUnsetWatermark = errors.New("failed to unset watermark")
)
type Watermark struct {
*common.Fields
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 IWatermark interface {
Set(io.Writer) error
Unset(io.Writer) error
}
type Option func(*Watermark)
func NewWatermark(opts ...Option) IWatermark {
w := &Watermark{Fields: &common.Fields{}}
for _, opt := range opts {
opt(w)
}
return w
}
func (w *Watermark) Set(writer io.Writer) error {
w.EnsureService()
file, err := pkg.Root.Open(w.File)
if err != nil {
return errors.Join(errSetWatermark, err)
}
defer func(file *os.File) {
_ = file.Close()
}(file)
inVideoBranding := &youtube.InvideoBranding{
Position: &youtube.InvideoPosition{},
Timing: &youtube.InvideoTiming{},
}
if w.InVideoPosition != "" {
inVideoBranding.Position.Type = "corner"
inVideoBranding.Position.CornerPosition = w.InVideoPosition
}
if w.DurationMs != 0 {
inVideoBranding.Timing.DurationMs = w.DurationMs
}
if w.OffsetMs != 0 {
inVideoBranding.Timing.OffsetMs = w.OffsetMs
}
if w.OffsetType != "" {
inVideoBranding.Timing.Type = w.OffsetType
}
call := w.Service.Watermarks.Set(w.ChannelId, inVideoBranding).Media(file)
if w.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(w.OnBehalfOfContentOwner)
}
err = call.Do()
if err != nil {
return errors.Join(errSetWatermark, err)
}
_, _ = fmt.Fprintf(writer, "Watermark set for channel %s\n", w.ChannelId)
return nil
}
func (w *Watermark) Unset(writer io.Writer) error {
w.EnsureService()
call := w.Service.Watermarks.Unset(w.ChannelId)
if w.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(w.OnBehalfOfContentOwner)
}
err := call.Do()
if err != nil {
return errors.Join(errUnsetWatermark, err)
}
_, _ = fmt.Fprintf(writer, "Watermark unset for channel %s\n", w.ChannelId)
return nil
}
func 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
}
}
var WithService = common.WithService[*Watermark]