vodular/youtube/youtube.go

361 lines
9.1 KiB
Go
Raw Normal View History

2026-01-28 10:48:14 +00:00
package youtube
import (
"bytes"
"context"
"errors"
2026-01-28 10:48:14 +00:00
"fmt"
"log"
"os"
"path"
"strings"
"text/template"
"time"
2026-01-30 18:53:09 +00:00
"arimelody.space/vodular/scanner"
2026-01-28 10:48:14 +00:00
"golang.org/x/oauth2"
"google.golang.org/api/youtube/v3"
)
const (
YT_CATEGORY_GAMING = "20"
YT_CATEGORY_ENTERTAINMENT = "24"
2026-01-28 10:48:14 +00:00
)
type CategoryType int
2026-01-28 10:48:14 +00:00
const (
CATEGORY_GAME CategoryType = iota
CATEGORY_ENTERTAINMENT
2026-01-28 10:48:14 +00:00
)
type (
Category struct {
Name string
Type CategoryType
Url string
2026-01-28 10:48:14 +00:00
}
VideoMetadata struct {
Title string
Category *Category
Part int
Date time.Time
Tags []string
Filepath string
SizeBytes int64
}
ThumbnailMetadata struct {
Filepath string
SizeBytes int64
2026-01-28 10:48:14 +00:00
}
)
const VIDEO_URL_BASE string = "https://www.youtube.com/watch?v="
func BuildVideo(metadata *scanner.Metadata) (*VideoMetadata, error) {
var category *Category = nil
if metadata.Category != nil {
category = &Category{
2026-01-28 10:48:14 +00:00
Name: metadata.Category.Name,
Type: CATEGORY_ENTERTAINMENT,
2026-01-28 10:48:14 +00:00
Url: metadata.Category.Url,
}
var ok bool
category.Type, ok = videoCategoryStringTypes[metadata.Category.Type]
if !ok { category.Type = CATEGORY_ENTERTAINMENT }
}
return &VideoMetadata{
Title: metadata.Title,
Category: category,
2026-01-28 10:48:14 +00:00
Part: metadata.Part,
Date: metadata.Date.AsTime(time.UTC),
2026-01-30 18:53:09 +00:00
Tags: metadata.Tags,
Filepath: path.Join(
2026-01-28 10:48:14 +00:00
metadata.FootageDir,
fmt.Sprintf(
"%s-fullvod.mkv",
metadata.Date.String(),
2026-01-28 10:48:14 +00:00
)),
}, nil
}
type (
MetaCategory struct {
2026-01-28 10:48:14 +00:00
Name string
Type string
Url string
}
Metadata struct {
Title string
2026-01-30 18:53:09 +00:00
Date time.Time
Category *MetaCategory
Part int
2026-01-28 10:48:14 +00:00
}
2026-01-30 18:53:09 +00:00
Template struct {
Title *template.Template
Description *template.Template
Tags []string
}
2026-01-28 10:48:14 +00:00
)
var videoCategoryTypeStrings = map[CategoryType]string{
CATEGORY_GAME: "gaming",
CATEGORY_ENTERTAINMENT: "entertainment",
}
var videoCategoryStringTypes = map[string]CategoryType{
"gaming": CATEGORY_GAME,
"entertainment": CATEGORY_ENTERTAINMENT,
}
var videoYtCategory = map[CategoryType]string {
CATEGORY_GAME: YT_CATEGORY_GAMING,
CATEGORY_ENTERTAINMENT: YT_CATEGORY_ENTERTAINMENT,
}
2026-01-30 18:53:09 +00:00
const defaultTitleTemplate =
"{{.Title}} - {{FormatTime .Date \"02 Jan 2006\"}}"
const defaultDescriptionTemplate =
"Streamed on {{FormatTime .Date \"02 January 2006\"}}"
2026-01-30 18:53:09 +00:00
var templateFuncs = template.FuncMap{
"FormatTime": func (time time.Time, format string) string {
return time.Format(format)
},
"ToLower": func (str string) string {
return strings.ToLower(str)
},
"ToUpper": func (str string) string {
return strings.ToUpper(str)
},
}
2026-01-31 03:10:28 +00:00
func FetchTemplates(templateDir string) (*Template, error) {
2026-01-30 18:53:09 +00:00
tmpl := Template{}
2026-01-31 03:10:28 +00:00
var tagsPath = path.Join(templateDir, "tags.txt")
var titlePath = path.Join(templateDir, "title.txt")
var descriptionPath = path.Join(templateDir, "description.txt")
2026-01-30 18:53:09 +00:00
// tags
if tagsFile, err := os.ReadFile(tagsPath); err == nil {
tmpl.Tags = strings.Split(string(tagsFile), "\n")
} else {
if !os.IsNotExist(err) { return nil, err }
log.Fatalf(
"%s not found. No default tags will be used.",
tagsPath,
)
tmpl.Tags = []string{}
}
// title
titleTemplate := template.New("title").Funcs(templateFuncs)
if titleFile, err := os.ReadFile(titlePath); err == nil {
tmpl.Title, err = titleTemplate.Parse(string(titleFile))
if err != nil {
return nil, fmt.Errorf("parse title template: %v", err)
}
2026-01-30 18:53:09 +00:00
} else {
if !os.IsNotExist(err) { return nil, err }
log.Fatalf(
"%s not found. Falling back to default template:\n%s",
titlePath,
defaultTitleTemplate,
)
tmpl.Title, err = titleTemplate.Parse(defaultTitleTemplate)
if err != nil { panic(err) }
os.WriteFile(titlePath, []byte(defaultTitleTemplate), 0644)
2026-01-28 10:48:14 +00:00
}
2026-01-30 18:53:09 +00:00
// description
descriptionTemplate := template.New("description").Funcs(templateFuncs,)
if descriptionFile, err := os.ReadFile(descriptionPath); err == nil {
tmpl.Description, err = descriptionTemplate.Parse(string(descriptionFile))
if err != nil {
return nil, fmt.Errorf("parse description template: %v", err)
2026-01-30 18:53:09 +00:00
}
} else {
if !os.IsNotExist(err) { return nil, err }
2026-01-28 10:48:14 +00:00
2026-01-30 18:53:09 +00:00
log.Fatalf(
"%s not found. Falling back to default template:\n%s",
descriptionPath,
defaultDescriptionTemplate,
)
tmpl.Description, err = descriptionTemplate.Parse(defaultDescriptionTemplate)
if err != nil { panic(err) }
os.WriteFile(descriptionPath, []byte(defaultDescriptionTemplate), 0644)
}
return &tmpl, nil
2026-01-28 10:48:14 +00:00
}
func BuildTemplate(video *VideoMetadata, tmpl *template.Template) (string, error) {
out := &bytes.Buffer{}
var category *MetaCategory
if video.Category != nil {
category = &MetaCategory{
Name: video.Category.Name,
Type: videoCategoryTypeStrings[video.Category.Type],
Url: video.Category.Url,
}
2026-01-28 10:48:14 +00:00
}
2026-01-30 18:53:09 +00:00
err := tmpl.Execute(out, Metadata{
Title: video.Title,
2026-01-30 18:53:09 +00:00
Date: video.Date,
Category: category,
2026-01-28 10:48:14 +00:00
Part: video.Part,
})
return strings.TrimSpace(out.String()), err
2026-01-28 10:48:14 +00:00
}
func UploadVideo(
ctx context.Context,
tokenSource oauth2.TokenSource,
service *youtube.Service,
video *VideoMetadata,
2026-01-30 18:53:09 +00:00
templates *Template,
) (*youtube.Video, error) {
if templates == nil { return nil, fmt.Errorf("templates cannot be nil") }
if templates.Title == nil { return nil, fmt.Errorf("title template cannot be nil") }
if templates.Description == nil { return nil, fmt.Errorf("description template cannot be nil") }
2026-01-30 18:53:09 +00:00
title, err := BuildTemplate(video, templates.Title)
if err != nil { return nil, fmt.Errorf("build title: %v", err) }
2026-01-30 18:53:09 +00:00
description, err := BuildTemplate(video, templates.Description)
if err != nil { return nil, fmt.Errorf("build description: %v", err) }
2026-01-28 10:48:14 +00:00
videoService := youtube.NewVideosService(service)
categoryId := YT_CATEGORY_ENTERTAINMENT
if video.Category != nil {
if cid, ok := videoYtCategory[video.Category.Type]; ok {
categoryId = cid
}
2026-01-28 10:48:14 +00:00
}
videoInsertCall := videoService.Insert([]string{
2026-01-28 10:48:14 +00:00
"snippet", "status",
}, &youtube.Video{
Snippet: &youtube.VideoSnippet{
Title: title,
Description: description,
2026-01-30 18:53:09 +00:00
Tags: append(templates.Tags, video.Tags...),
2026-01-28 10:48:14 +00:00
CategoryId: categoryId, // gaming
},
Status: &youtube.VideoStatus{
PrivacyStatus: "private",
},
}).NotifySubscribers(false)
videoInsertCall.Context(ctx)
2026-01-28 10:48:14 +00:00
videoFile, err := os.Open(video.Filepath)
2026-01-28 10:48:14 +00:00
if err != nil {
log.Fatalf("Failed to open video: %v\n", err)
2026-01-28 12:50:11 +00:00
return nil, err
2026-01-28 10:48:14 +00:00
}
videoInsertCall.Media(videoFile)
2026-01-28 10:48:14 +00:00
log.Println("Uploading video...")
videoInsertCall.ProgressUpdater(func(current, total int64) {
// for some reason, this only returns 0.
// instead, we pull the file size from the ffmpeg output directly.
if total == 0 { total = video.SizeBytes }
fmt.Printf("\t(%.2f%%)\n", float64(current) / float64(total) * 100)
})
ytVideo, err := videoInsertCall.Do()
2026-01-28 10:48:14 +00:00
if err != nil {
log.Fatalf("Failed to upload video: %v\n", err)
2026-01-28 12:50:11 +00:00
return nil, err
2026-01-28 10:48:14 +00:00
}
return ytVideo, err
}
func UpdateMetadata(
ctx context.Context,
tokenSource oauth2.TokenSource,
service *youtube.Service,
metadata *VideoMetadata,
videoId string,
templates *Template,
) error {
if templates == nil { return fmt.Errorf("templates cannot be nil") }
if templates.Title == nil { return fmt.Errorf("title template cannot be nil") }
if templates.Description == nil { return fmt.Errorf("description template cannot be nil") }
metadataService := youtube.NewVideosService(service)
videoFetchCall := metadataService.List([]string{"id", "snippet"})
videoFetchCall.Context(ctx)
videoFetchCall.Id(videoId)
videoList, err := videoFetchCall.Do()
if err != nil { return fmt.Errorf("fetch video: %v", err) }
if len(videoList.Items) == 0 { return errors.New("video not found") }
var video *youtube.Video
for _, v := range videoList.Items {
if v.Id == videoId {
video = v
break
2026-06-23 19:07:51 +01:00
}
}
if video == nil { return errors.New("Could not find video. Was it deleted?") }
if video.Snippet == nil { return errors.New("video snippet is nil") }
video.Snippet.Title, err = BuildTemplate(metadata, templates.Title)
if err != nil { return fmt.Errorf("build title: %v", err) }
video.Snippet.Description, err = BuildTemplate(metadata, templates.Description)
if err != nil { return fmt.Errorf("build description: %v", err) }
video.Snippet.Tags = metadata.Tags
video.Snippet.CategoryId = videoYtCategory[metadata.Category.Type]
metadataSetCall := metadataService.Update([]string{"snippet"}, video)
metadataSetCall.Context(ctx)
if video, err = metadataSetCall.Do(); err != nil {
return fmt.Errorf("set metadata: %v", err)
}
return nil
}
func UploadThumbnail(
ctx context.Context,
tokenSource oauth2.TokenSource,
service *youtube.Service,
videoId string,
thumbnail *ThumbnailMetadata,
) error {
if thumbnail == nil {
return fmt.Errorf("thumbnail cannot be nil")
}
thumbnailService := youtube.NewThumbnailsService(service)
thumbnailSetCall := thumbnailService.Set(videoId)
thumbnailSetCall.Context(ctx)
thumbnailFile, err := os.Open(thumbnail.Filepath)
if err != nil { return fmt.Errorf("open thumbnail: %v", err) }
thumbnailSetCall.Media(thumbnailFile)
thumbnailSetCall.ProgressUpdater(func(current, total int64) {
if total == 0 { total = thumbnail.SizeBytes }
fmt.Printf("\t(%.2f%%)\n", float64(current) / float64(total) * 100)
})
_, err = thumbnailSetCall.Do()
if err != nil {
log.Printf("Failed to upload thumbnail: %v\n", err)
}
return nil
2026-01-28 10:48:14 +00:00
}