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