// 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(pkg.TableStyle)
tb.AppendHeader(table.Row{"ID", "Title", "Channel ID", "Views"})
for _, video := range videos {
tb.AppendRow(
table.Row{
video.Id, video.Snippet.Title,
video.Snippet.ChannelId,
video.Statistics.ViewCount,
},
)
}
}
return err
}
func (v *video) Insert(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(pkg.TableStyle)
tb.AppendHeader(table.Row{"ID", "Rating"})
for _, item := range res.Items {
tb.AppendRow(table.Row{item.VideoId, item.Rating})
}
}
return nil
}
func (v *video) Delete(writer io.Writer) error {
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
}
}