2026-01-28 10:48:14 +00:00
|
|
|
package youtube
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"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/option"
|
|
|
|
|
"google.golang.org/api/youtube/v3"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
2026-01-30 14:14:54 +00:00
|
|
|
YT_CATEGORY_GAMING = "20"
|
|
|
|
|
YT_CATEGORY_ENTERTAINMENT = "24"
|
2026-01-28 10:48:14 +00:00
|
|
|
)
|
|
|
|
|
|
2026-01-30 14:14:54 +00:00
|
|
|
type CategoryType int
|
2026-01-28 10:48:14 +00:00
|
|
|
const (
|
2026-01-30 14:14:54 +00:00
|
|
|
CATEGORY_GAME CategoryType = iota
|
|
|
|
|
CATEGORY_ENTERTAINMENT
|
2026-01-28 10:48:14 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type (
|
2026-01-30 14:14:54 +00:00
|
|
|
Category struct {
|
|
|
|
|
Name string
|
|
|
|
|
Type CategoryType
|
|
|
|
|
Url string
|
2026-01-28 10:48:14 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Video struct {
|
2026-01-30 14:14:54 +00:00
|
|
|
Title string
|
|
|
|
|
Category *Category
|
|
|
|
|
Part int
|
|
|
|
|
Date time.Time
|
|
|
|
|
Tags []string
|
|
|
|
|
Filename string
|
|
|
|
|
SizeBytes int64
|
2026-01-28 10:48:14 +00:00
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func BuildVideo(metadata *scanner.Metadata) (*Video, error) {
|
|
|
|
|
videoDate, err := time.Parse("2006-01-02", metadata.Date)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to parse date from metadata: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 14:14:54 +00:00
|
|
|
var category *Category = nil
|
|
|
|
|
if metadata.Category != nil {
|
|
|
|
|
category = &Category{
|
2026-01-28 10:48:14 +00:00
|
|
|
Name: metadata.Category.Name,
|
2026-01-30 14:14:54 +00:00
|
|
|
Type: CATEGORY_ENTERTAINMENT,
|
2026-01-28 10:48:14 +00:00
|
|
|
Url: metadata.Category.Url,
|
2026-01-30 14:14:54 +00:00
|
|
|
}
|
|
|
|
|
var ok bool
|
|
|
|
|
category.Type, ok = videoCategoryStringTypes[metadata.Category.Type]
|
|
|
|
|
if !ok { category.Type = CATEGORY_ENTERTAINMENT }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &Video{
|
|
|
|
|
Title: metadata.Title,
|
|
|
|
|
Category: category,
|
2026-01-28 10:48:14 +00:00
|
|
|
Part: metadata.Part,
|
|
|
|
|
Date: videoDate,
|
2026-01-30 18:53:09 +00:00
|
|
|
Tags: metadata.Tags,
|
2026-01-28 10:48:14 +00:00
|
|
|
Filename: path.Join(
|
|
|
|
|
metadata.FootageDir,
|
|
|
|
|
fmt.Sprintf(
|
|
|
|
|
"%s-fullvod.mkv",
|
|
|
|
|
videoDate.Format("2006-01-02"),
|
|
|
|
|
)),
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type (
|
2026-01-30 14:14:54 +00:00
|
|
|
MetaCategory struct {
|
2026-01-28 10:48:14 +00:00
|
|
|
Name string
|
|
|
|
|
Type string
|
|
|
|
|
Url string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Metadata struct {
|
2026-01-30 14:14:54 +00:00
|
|
|
Title string
|
2026-01-30 18:53:09 +00:00
|
|
|
Date time.Time
|
2026-01-30 14:14:54 +00:00
|
|
|
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
|
|
|
)
|
|
|
|
|
|
2026-01-30 14:14:54 +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 14:14:54 +00:00
|
|
|
|
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-30 14:14:54 +00:00
|
|
|
}
|
|
|
|
|
|
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-30 14:14:54 +00:00
|
|
|
|
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("failed to parse title template: %v", err)
|
2026-01-30 14:14:54 +00:00
|
|
|
}
|
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("failed to parse description template: %v", err)
|
|
|
|
|
}
|
|
|
|
|
} 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
|
|
|
}
|
|
|
|
|
|
2026-01-30 18:53:09 +00:00
|
|
|
func BuildTemplate(video *Video, tmpl *template.Template) (string, error) {
|
2026-01-30 14:14:54 +00:00
|
|
|
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{
|
2026-01-30 14:14:54 +00:00
|
|
|
Title: video.Title,
|
2026-01-30 18:53:09 +00:00
|
|
|
Date: video.Date,
|
2026-01-30 14:14:54 +00:00
|
|
|
Category: category,
|
2026-01-28 10:48:14 +00:00
|
|
|
Part: video.Part,
|
|
|
|
|
})
|
|
|
|
|
|
2026-01-30 14:14:54 +00:00
|
|
|
return strings.TrimSpace(out.String()), err
|
2026-01-28 10:48:14 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-30 15:55:57 +00:00
|
|
|
func UploadVideo(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
tokenSource oauth2.TokenSource,
|
|
|
|
|
video *Video,
|
2026-01-30 18:53:09 +00:00
|
|
|
templates *Template,
|
2026-01-30 15:55:57 +00:00
|
|
|
) (*youtube.Video, error) {
|
2026-01-30 18:53:09 +00:00
|
|
|
title, err := BuildTemplate(video, templates.Title)
|
|
|
|
|
if err != nil { return nil, fmt.Errorf("failed to build title: %v", err) }
|
|
|
|
|
description, err := BuildTemplate(video, templates.Description)
|
|
|
|
|
if err != nil { return nil, fmt.Errorf("failed to build description: %v", err) }
|
2026-01-28 10:48:14 +00:00
|
|
|
|
|
|
|
|
service, err := youtube.NewService(
|
|
|
|
|
ctx,
|
|
|
|
|
option.WithScopes(youtube.YoutubeUploadScope),
|
2026-01-30 15:55:57 +00:00
|
|
|
option.WithTokenSource(tokenSource),
|
2026-01-28 10:48:14 +00:00
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Fatalf("Failed to create youtube service: %v\n", err)
|
2026-01-28 12:50:11 +00:00
|
|
|
return nil, err
|
2026-01-28 10:48:14 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
videoService := youtube.NewVideosService(service)
|
|
|
|
|
|
2026-01-30 14:14:54 +00:00
|
|
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
call := videoService.Insert([]string{
|
|
|
|
|
"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)
|
|
|
|
|
|
|
|
|
|
file, err := os.Open(video.Filename)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Fatalf("Failed to open file: %v\n", err)
|
2026-01-28 12:50:11 +00:00
|
|
|
return nil, err
|
2026-01-28 10:48:14 +00:00
|
|
|
}
|
|
|
|
|
call.Media(file)
|
|
|
|
|
|
|
|
|
|
log.Println("Uploading video...")
|
|
|
|
|
|
2026-01-30 14:14:54 +00:00
|
|
|
call.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 }
|
2026-01-30 14:45:58 +00:00
|
|
|
fmt.Printf("Uploading... (%.2f%%)\n", float64(current) / float64(total) * 100)
|
2026-01-30 14:14:54 +00:00
|
|
|
})
|
2026-01-28 12:50:11 +00:00
|
|
|
ytVideo, err := call.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
|
|
|
}
|
|
|
|
|
|
2026-01-28 12:50:11 +00:00
|
|
|
return ytVideo, err
|
2026-01-28 10:48:14 +00:00
|
|
|
}
|