Files

713 lines
18 KiB
Go

// Copyright 2025 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package video
import (
"errors"
"fmt"
"io"
"os"
"slices"
"github.com/eat-pray-ai/yutu/pkg"
"github.com/eat-pray-ai/yutu/pkg/common"
"github.com/eat-pray-ai/yutu/pkg/playlistItem"
"github.com/eat-pray-ai/yutu/pkg/thumbnail"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/eat-pray-ai/yutu/pkg/utils"
"google.golang.org/api/youtube/v3"
)
var (
errGetVideo = errors.New("failed to get video")
errInsertVideo = errors.New("failed to insert video")
errUpdateVideo = errors.New("failed to update video")
errRating = errors.New("failed to rate video")
errGetRating = errors.New("failed to get rating")
errDeleteVideo = errors.New("failed to delete video")
errReportAbuse = errors.New("failed to report abuse")
errScheduleNonPrivate = errors.New(
"publishAt requires privacy private; a public or unlisted video would go offline until the scheduled time",
)
)
type Video struct {
common.Fields
AutoLevels *bool `yaml:"auto_levels" json:"auto_levels,omitzero"`
File string `yaml:"file" json:"file,omitempty"`
Title string `yaml:"title" json:"title,omitempty"`
Description *string `yaml:"description" json:"description,omitzero"`
Tags []string `yaml:"tags" json:"tags,omitempty"`
Language *string `yaml:"language" json:"language,omitzero"`
Locale string `yaml:"locale" json:"locale,omitempty"`
License string `yaml:"license" json:"license,omitempty"`
Thumbnail string `yaml:"thumbnail" json:"thumbnail,omitempty"`
Rating string `yaml:"rating" json:"rating,omitempty"`
Chart string `yaml:"chart" json:"chart,omitempty"`
Comments string `yaml:"comments" json:"comments,omitempty"`
PlaylistId string `yaml:"playlist_id" json:"playlist_id,omitempty"`
CategoryId string `yaml:"category_id" json:"category_id,omitempty"`
Privacy string `yaml:"privacy" json:"privacy,omitempty"`
ForKids *bool `yaml:"for_kids" json:"for_kids,omitzero"`
Embeddable *bool `yaml:"embeddable" json:"embeddable,omitzero"`
PublishAt *string `yaml:"publish_at" json:"publish_at,omitzero"`
RegionCode string `yaml:"region_code" json:"region_code,omitempty"`
ReasonId string `yaml:"reason_id" json:"reason_id,omitempty"`
Stabilize *bool `yaml:"stabilize" json:"stabilize,omitzero"`
MaxHeight int64 `yaml:"max_height" json:"max_height,omitzero"`
MaxWidth int64 `yaml:"max_width" json:"max_width,omitzero"`
RecordingDate *string `yaml:"recording_date" json:"recording_date,omitzero"`
ContainsSyntheticMedia *bool `yaml:"contains_synthetic_media" json:"contains_synthetic_media,omitzero"`
SecondaryReasonId string `yaml:"secondary_reason_id" json:"secondary_reason_id,omitempty"`
NotifySubscribers *bool `yaml:"notify_subscribers" json:"notify_subscribers,omitzero"`
PublicStatsViewable *bool `yaml:"public_stats_viewable" json:"public_stats_viewable,omitzero"`
OnBehalfOfContentOwnerChannel string `yaml:"on_behalf_of_content_owner_channel" json:"on_behalf_of_content_owner_channel,omitempty"`
}
type IVideo[T any] interface {
List(io.Writer) error
Insert(io.Writer) error
Update(io.Writer) error
Rate(io.Writer) error
GetRating(io.Writer) error
Delete(io.Writer) error
ReportAbuse(io.Writer) error
Get() ([]*T, error)
}
type Option func(*Video)
func NewVideo(opts ...Option) IVideo[youtube.Video] {
v := &Video{Fields: common.Fields{}}
for _, opt := range opts {
opt(v)
}
return v
}
func (v *Video) Get() ([]*youtube.Video, error) {
if err := v.EnsureService(); err != nil {
return nil, err
}
call := v.Service.Videos.List(v.Parts)
if len(v.Ids) > 0 {
call = call.Id(v.Ids...)
}
if v.Chart != "" {
call = call.Chart(v.Chart)
}
if v.Hl != "" {
call = call.Hl(v.Hl)
}
if v.Locale != "" {
call = call.Locale(v.Locale)
}
if v.CategoryId != "" {
call = call.VideoCategoryId(v.CategoryId)
}
if v.Rating != "" {
call = call.MyRating(v.Rating)
}
if v.RegionCode != "" {
call = call.RegionCode(v.RegionCode)
}
if v.MaxHeight != 0 {
call = call.MaxHeight(v.MaxHeight)
}
if v.MaxWidth != 0 {
call = call.MaxWidth(v.MaxWidth)
}
if v.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(v.OnBehalfOfContentOwner)
}
return common.Paginate(
&v.Fields, call,
func(r *youtube.VideoListResponse) ([]*youtube.Video, string) {
return r.Items, r.NextPageToken
}, errGetVideo,
)
}
func (v *Video) List(writer io.Writer) error {
videos, err := v.Get()
if err != nil && videos == nil {
return err
}
common.PrintList(
v.Output, videos, writer, table.Row{"ID", "Title", "Channel ID", "Views"},
func(video *youtube.Video) table.Row {
title := ""
channelId := ""
var views uint64
if video.Snippet != nil {
title = video.Snippet.Title
channelId = video.Snippet.ChannelId
}
if video.Statistics != nil {
views = video.Statistics.ViewCount
}
return table.Row{video.Id, title, channelId, views}
},
)
return err
}
func (v *Video) Insert(writer io.Writer) error {
if err := v.EnsureService(); err != nil {
return err
}
file, err := pkg.Root.Open(v.File)
if err != nil {
return errors.Join(errInsertVideo, err)
}
defer func(file *os.File) {
_ = file.Close()
}(file)
if !slices.Contains(v.Tags, "yutu🐰") {
v.Tags = append(v.Tags, "yutu🐰")
}
if v.Title == "" {
v.Title = utils.GetFileName(v.File)
}
video := &youtube.Video{
Snippet: &youtube.VideoSnippet{
Title: v.Title,
Tags: v.Tags,
CategoryId: v.CategoryId,
ChannelId: v.ChannelId,
},
Status: &youtube.VideoStatus{
License: v.License,
PrivacyStatus: v.Privacy,
ForceSendFields: []string{"SelfDeclaredMadeForKids", "ContainsSyntheticMedia"},
},
}
if v.Description != nil {
video.Snippet.Description = *v.Description
}
if v.Language != nil {
video.Snippet.DefaultLanguage = *v.Language
video.Snippet.DefaultAudioLanguage = *v.Language
}
if v.PublishAt != nil {
video.Status.PublishAt = *v.PublishAt
}
if v.Embeddable != nil {
video.Status.Embeddable = *v.Embeddable
video.Status.ForceSendFields = append(
video.Status.ForceSendFields, "Embeddable",
)
}
if v.ForKids != nil {
video.Status.SelfDeclaredMadeForKids = *v.ForKids
}
if v.ContainsSyntheticMedia != nil {
video.Status.ContainsSyntheticMedia = *v.ContainsSyntheticMedia
}
if v.PublicStatsViewable != nil {
video.Status.PublicStatsViewable = *v.PublicStatsViewable
video.Status.ForceSendFields = append(
video.Status.ForceSendFields, "PublicStatsViewable",
)
}
insertParts := "snippet,status"
if v.RecordingDate != nil && *v.RecordingDate != "" {
video.RecordingDetails = &youtube.VideoRecordingDetails{
RecordingDate: *v.RecordingDate,
}
insertParts += ",recordingDetails"
}
call := v.Service.Videos.Insert([]string{insertParts}, video)
if v.AutoLevels != nil {
call = call.AutoLevels(*v.AutoLevels)
}
if v.NotifySubscribers != nil {
call = call.NotifySubscribers(*v.NotifySubscribers)
}
if v.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(v.OnBehalfOfContentOwner)
}
if v.OnBehalfOfContentOwnerChannel != "" {
call = call.OnBehalfOfContentOwnerChannel(v.OnBehalfOfContentOwnerChannel)
}
if v.Stabilize != nil {
call = call.Stabilize(*v.Stabilize)
}
res, err := call.Media(file).Do()
if err != nil {
return errors.Join(errInsertVideo, err)
}
if v.Thumbnail != "" {
t := thumbnail.NewThumbnail(
thumbnail.WithVideoId(res.Id),
thumbnail.WithFile(v.Thumbnail),
thumbnail.WithService(v.Service),
thumbnail.WithOutput("silent"),
)
_ = t.Set(nil)
}
if v.PlaylistId != "" {
pi := playlistItem.NewPlaylistItem(
playlistItem.WithTitle(res.Snippet.Title),
playlistItem.WithDescription(&res.Snippet.Description),
playlistItem.WithKind("video"),
playlistItem.WithKVideoId(res.Id),
playlistItem.WithPlaylistId(v.PlaylistId),
playlistItem.WithChannelId(res.Snippet.ChannelId),
playlistItem.WithPrivacy(res.Status.PrivacyStatus),
playlistItem.WithService(v.Service),
playlistItem.WithOutput("silent"),
)
_ = pi.Insert(writer)
}
common.PrintResult(v.Output, res, writer, "Video inserted: %s\n", res.Id)
return nil
}
// Update preserves nil Description, Language, PublishAt, and RecordingDate inputs.
// Non-nil empty strings clear those properties by omitting them from the updated
// resource part; timestamp clears must not be sent as empty timestamp strings.
func (v *Video) Update(writer io.Writer) error {
if err := v.EnsureService(); err != nil {
return err
}
v.Parts = []string{"id", "snippet", "status"}
videos, err := v.Get()
if err != nil {
return errors.Join(errUpdateVideo, err)
}
if len(videos) == 0 {
return errGetVideo
}
original := videos[0]
// Build a new video with only writable fields to avoid sending
// read-only fields (thumbnails, channelId, etc.) that cause
// invalidVideoMetadata errors.
video := &youtube.Video{
Id: original.Id,
Snippet: &youtube.VideoSnippet{},
Status: &youtube.VideoStatus{
// Status updates replace existing values; omitempty must not drop false booleans.
ForceSendFields: []string{"Embeddable", "PublicStatsViewable", "SelfDeclaredMadeForKids", "ContainsSyntheticMedia"},
},
}
if original.Snippet != nil {
video.Snippet.Title = original.Snippet.Title
video.Snippet.Description = original.Snippet.Description
video.Snippet.Tags = original.Snippet.Tags
video.Snippet.CategoryId = original.Snippet.CategoryId
video.Snippet.DefaultLanguage = original.Snippet.DefaultLanguage
}
if original.Status != nil {
video.Status.Embeddable = original.Status.Embeddable
video.Status.License = original.Status.License
video.Status.PrivacyStatus = original.Status.PrivacyStatus
video.Status.PublicStatsViewable = original.Status.PublicStatsViewable
video.Status.PublishAt = original.Status.PublishAt
video.Status.SelfDeclaredMadeForKids = original.Status.SelfDeclaredMadeForKids
video.Status.ContainsSyntheticMedia = original.Status.ContainsSyntheticMedia
}
if v.Title != "" {
video.Snippet.Title = v.Title
}
if v.Description != nil {
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 != nil {
video.Snippet.DefaultLanguage = *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.PublishAt != nil {
// YouTube only accepts a schedule on private videos. Do not coerce
// public/unlisted to private here: that would take a live video offline.
if *v.PublishAt != "" && video.Status.PrivacyStatus != "private" {
return errors.Join(errUpdateVideo, errScheduleNonPrivate)
}
video.Status.PublishAt = *v.PublishAt
}
if v.Embeddable != nil {
video.Status.Embeddable = *v.Embeddable
}
if v.ContainsSyntheticMedia != nil {
video.Status.ContainsSyntheticMedia = *v.ContainsSyntheticMedia
}
updateParts := "snippet,status"
if v.RecordingDate != nil {
video.RecordingDetails = &youtube.VideoRecordingDetails{
RecordingDate: *v.RecordingDate,
}
updateParts += ",recordingDetails"
}
call := v.Service.Videos.Update([]string{updateParts}, video)
if v.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(v.OnBehalfOfContentOwner)
}
res, err := call.Do()
if err != nil {
return errors.Join(errUpdateVideo, err)
}
if v.Thumbnail != "" {
t := thumbnail.NewThumbnail(
thumbnail.WithVideoId(res.Id),
thumbnail.WithFile(v.Thumbnail),
thumbnail.WithService(v.Service),
thumbnail.WithOutput("silent"),
)
_ = t.Set(nil)
}
if v.PlaylistId != "" {
pi := playlistItem.NewPlaylistItem(
playlistItem.WithTitle(res.Snippet.Title),
playlistItem.WithDescription(&res.Snippet.Description),
playlistItem.WithKind("video"),
playlistItem.WithKVideoId(res.Id),
playlistItem.WithPlaylistId(v.PlaylistId),
playlistItem.WithChannelId(res.Snippet.ChannelId),
playlistItem.WithPrivacy(res.Status.PrivacyStatus),
playlistItem.WithService(v.Service),
playlistItem.WithOutput("silent"),
)
_ = pi.Insert(writer)
}
common.PrintResult(v.Output, res, writer, "Video updated: %s\n", res.Id)
return nil
}
func (v *Video) Rate(writer io.Writer) error {
if err := v.EnsureService(); err != nil {
return err
}
for _, id := range v.Ids {
call := v.Service.Videos.Rate(id, v.Rating)
err := call.Do()
if err != nil {
return errors.Join(errRating, err)
}
_, _ = fmt.Fprintf(writer, "Video %s rated %s\n", id, v.Rating)
}
return nil
}
func (v *Video) GetRating(writer io.Writer) error {
if err := v.EnsureService(); err != nil {
return err
}
call := v.Service.Videos.GetRating(v.Ids)
if v.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(v.OnBehalfOfContentOwner)
}
res, err := call.Do()
if err != nil {
return errors.Join(errGetRating, err)
}
switch v.Output {
case "json":
utils.PrintJSON(res.Items, writer)
case "yaml":
utils.PrintYAML(res.Items, writer)
default:
tb := table.NewWriter()
defer tb.Render()
tb.SetOutputMirror(writer)
tb.SetStyle(pkg.TableStyle)
tb.AppendHeader(table.Row{"ID", "Rating"})
for _, item := range res.Items {
tb.AppendRow(table.Row{item.VideoId, item.Rating})
}
}
return nil
}
func (v *Video) Delete(writer io.Writer) error {
if err := v.EnsureService(); err != nil {
return err
}
for _, id := range v.Ids {
call := v.Service.Videos.Delete(id)
if v.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(v.OnBehalfOfContentOwner)
}
err := call.Do()
if err != nil {
return errors.Join(errDeleteVideo, err)
}
_, _ = fmt.Fprintf(writer, "Video %s deleted", id)
}
return nil
}
func (v *Video) ReportAbuse(writer io.Writer) error {
if err := v.EnsureService(); err != nil {
return err
}
for _, id := range v.Ids {
videoAbuseReport := &youtube.VideoAbuseReport{
Comments: v.Comments,
ReasonId: v.ReasonId,
SecondaryReasonId: v.SecondaryReasonId,
VideoId: id,
}
if v.Language != nil {
videoAbuseReport.Language = *v.Language
}
call := v.Service.Videos.ReportAbuse(videoAbuseReport)
if v.OnBehalfOfContentOwner != "" {
call = call.OnBehalfOfContentOwner(v.OnBehalfOfContentOwner)
}
err := call.Do()
if err != nil {
return errors.Join(errReportAbuse, err)
}
_, _ = fmt.Fprintf(writer, "Video %s reported for abuse", id)
}
return nil
}
func WithAutoLevels(autoLevels *bool) Option {
return func(v *Video) {
if autoLevels != nil {
v.AutoLevels = autoLevels
}
}
}
func WithFile(file string) Option {
return func(v *Video) {
v.File = file
}
}
func WithTitle(title string) Option {
return func(v *Video) {
v.Title = title
}
}
func WithDescription(description *string) Option {
return func(v *Video) {
v.Description = description
}
}
func WithTags(tags []string) Option {
return func(v *Video) {
v.Tags = tags
}
}
func WithLanguage(language *string) Option {
return func(v *Video) {
v.Language = language
}
}
func WithLocale(locale string) Option {
return func(v *Video) {
v.Locale = locale
}
}
func WithLicense(license string) Option {
return func(v *Video) {
v.License = license
}
}
func WithThumbnail(thumbnail string) Option {
return func(v *Video) {
v.Thumbnail = thumbnail
}
}
func WithRating(rating string) Option {
return func(v *Video) {
v.Rating = rating
}
}
func WithChart(chart string) Option {
return func(v *Video) {
v.Chart = chart
}
}
func WithForKids(forKids *bool) Option {
return func(v *Video) {
if forKids != nil {
v.ForKids = forKids
}
}
}
func WithEmbeddable(embeddable *bool) Option {
return func(v *Video) {
if embeddable != nil {
v.Embeddable = embeddable
}
}
}
func WithContainsSyntheticMedia(containsSyntheticMedia *bool) Option {
return func(v *Video) {
if containsSyntheticMedia != nil {
v.ContainsSyntheticMedia = containsSyntheticMedia
}
}
}
func WithCategory(categoryId string) Option {
return func(v *Video) {
v.CategoryId = categoryId
}
}
func WithPrivacy(privacy string) Option {
return func(v *Video) {
v.Privacy = privacy
}
}
func WithPlaylistId(playlistId string) Option {
return func(v *Video) {
v.PlaylistId = playlistId
}
}
func WithPublicStatsViewable(publicStatsViewable *bool) Option {
return func(v *Video) {
if publicStatsViewable != nil {
v.PublicStatsViewable = publicStatsViewable
}
}
}
func WithPublishAt(publishAt *string) Option {
return func(v *Video) {
v.PublishAt = publishAt
}
}
func WithRecordingDate(recordingDate *string) Option {
return func(v *Video) {
v.RecordingDate = recordingDate
}
}
func WithRegionCode(regionCode string) Option {
return func(v *Video) {
v.RegionCode = regionCode
}
}
func WithStabilize(stabilize *bool) Option {
return func(v *Video) {
if stabilize != nil {
v.Stabilize = stabilize
}
}
}
func WithMaxHeight(maxHeight int64) Option {
return func(v *Video) {
v.MaxHeight = maxHeight
}
}
func WithMaxWidth(maxWidth int64) Option {
return func(v *Video) {
v.MaxWidth = maxWidth
}
}
func WithNotifySubscribers(notifySubscribers *bool) Option {
return func(v *Video) {
if notifySubscribers != nil {
v.NotifySubscribers = notifySubscribers
}
}
}
func WithOnBehalfOfContentOwnerChannel(onBehalfOfContentOwnerChannel string) Option {
return func(v *Video) {
v.OnBehalfOfContentOwnerChannel = onBehalfOfContentOwnerChannel
}
}
func WithComments(comments string) Option {
return func(v *Video) {
v.Comments = comments
}
}
func WithReasonId(reasonId string) Option {
return func(v *Video) {
v.ReasonId = reasonId
}
}
func WithSecondaryReasonId(secondaryReasonId string) Option {
return func(v *Video) {
v.SecondaryReasonId = secondaryReasonId
}
}
var (
WithParts = common.WithParts[*Video]
WithOutput = common.WithOutput[*Video]
WithService = common.WithService[*Video]
WithIds = common.WithIds[*Video]
WithMaxResults = common.WithMaxResults[*Video]
WithHl = common.WithHl[*Video]
WithChannelId = common.WithChannelId[*Video]
WithOnBehalfOfContentOwner = common.WithOnBehalfOfContentOwner[*Video]
)