Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions cmd/upload/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
DESCRIPTION
upload provides a simple command-line utility for uploading videos to
AusOcean's YouTube account.

AUTHORS
Saxon Nelson-Milton <saxon@ausocean.org>

LICENSE
Copyright (C) 2025 the Australian Ocean Lab (AusOcean)

This file is part of Ocean TV. Ocean TV is free software: you can
redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option)
any later version.

Ocean TV is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
in gpl.txt. If not, see <http://www.gnu.org/licenses/>.
*/

// Upload provides a command-line utility for uploading videos to
// AusOcean's YouTube account.
// It uses the YouTube Data API v3 to handle video uploads and metadata.
// It assumes YouTube secrets are pointed at by an environment variable
// YOUTUBE_SECRETS, which should contain the path to a JSON file.
package main

import (
"context"
"flag"
"log"
"os"
"time"

"github.com/ausocean/cloud/youtube"
)

func main() {
media := flag.String("media", "", "Path to the video file to upload")
flag.Parse()

// Create io.Reader for the media file
reader, err := os.Open(*media)
if err != nil {
log.Fatalf("Failed to create media reader: %v", err)
}

// Example usage
err = youtube.UploadVideo(
context.Background(),
reader,
youtube.WithTitle("Test upload "+time.Now().Format("2006-01-02 15:04:05")),
youtube.WithDescription("This is a test upload"),
youtube.WithCategory("28"), // Science & Technology
youtube.WithPrivacy("unlisted"),
youtube.WithTags([]string{"test", "upload"}),
)
if err != nil {
log.Fatalf("Failed to upload video: %v", err)
}
}
250 changes: 250 additions & 0 deletions youtube/upload.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
/*
DESCRIPTION
upload.go provides functionality for uploading videos to YouTube

AUTHORS
Saxon Nelson-Milton <saxon@ausocean.org>

LICENSE
Copyright (C) 2025 the Australian Ocean Lab (AusOcean)

This file is part of Ocean TV. Ocean TV is free software: you can
redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option)
any later version.

Ocean TV is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
in gpl.txt. If not, see <http://www.gnu.org/licenses/>.
*/

package youtube

import (
"context"
"fmt"
"io"
"time"

"github.com/ausocean/cloud/cmd/oceantv/broadcast"
"github.com/ausocean/cloud/utils"
"google.golang.org/api/youtube/v3"
)

// VideoUploadOption is a functional option type for configuring YouTube video uploads.
type VideoUploadOption func(*youtube.Video) error

// WithTitle sets the title of the video being uploaded.
// It returns an error if the title is empty.
func WithTitle(title string) VideoUploadOption {
return func(video *youtube.Video) error {
if title == "" {
return fmt.Errorf("title cannot be empty")
}
video.Snippet.Title = title
return nil
}
}

// WithDescription sets the description of the video being uploaded.
// It returns an error if the description is empty.
func WithDescription(description string) VideoUploadOption {
return func(video *youtube.Video) error {
if description == "" {
return fmt.Errorf("description cannot be empty")
}
video.Snippet.Description = description
return nil
}
}

// WithCategory sets the category of the video being uploaded.
// It accepts either a category ID or a category name (both as strings) from the following:
//
// 1 - Film & Animation
// 2 - Autos & Vehicles
// 10 - Music
// 15 - Pets & Animals
// 17 - Sports
// 18 - Short Movies
// 19 - Travel & Events
// 20 - Gaming
// 21 - Videoblogging
// 22 - People & Blogs
// 23 - Comedy
// 24 - Entertainment
// 25 - News & Politics
// 26 - Howto & Style
// 27 - Education
// 28 - Science & Technology
// 29 - Nonprofits & Activism
// 30 - Movies
// 31 - Anime/Animation
// 32 - Action/Adventure
// 33 - Classics
// 34 - Comedy
// 35 - Documentary
// 36 - Drama
// 37 - Family
// 38 - Foreign
// 39 - Horror
// 40 - Sci-Fi/Fantasy
// 41 - Thriller
// 42 - Shorts
// 43 - Shows
// 44 - Trailers
//
// If a name is provided, it will be matched against a predefined list of categories
// and the corresponding ID will be used.
// It returns an error if the category ID/name is not found.
func WithCategory(categoryID string) VideoUploadOption {
return func(video *youtube.Video) error {
video.Snippet.CategoryId = sanitiseCategory(categoryID)
if video.Snippet.CategoryId == "" {
return fmt.Errorf("invalid category ID or name: %s", categoryID)
}
return nil
}
}

// WithPrivacy sets the privacy status of the video being uploaded.
// It accepts "public", "unlisted", or "private" as valid privacy statuses.
// It returns an error if the privacy status is empty or invalid.
func WithPrivacy(privacy string) VideoUploadOption {
return func(video *youtube.Video) error {
if !validPrivacy(privacy) {
return fmt.Errorf("invalid privacy status: %s", privacy)
}
video.Status.PrivacyStatus = privacy
return nil
}
}

// WithTags sets the tags for the video being uploaded.
// It returns an error if the tags slice is empty.
func WithTags(tags []string) VideoUploadOption {
return func(video *youtube.Video) error {
if len(tags) == 0 {
return fmt.Errorf("tags cannot be empty")
}
video.Snippet.Tags = tags
return nil
}
}

// UploadVideo uploads a video to AusOcean's YouTube account using the provided media reader and options.
// Defaults are applied for title, description, category, privacy, and tags if not specified in options.
// Defaults are as follows:
// - Title: "Uploaded at <current time>"
// - Description: "No description provided."
// - Category: "Science & Technology" (ID: 28)
// - Privacy: "unlisted"
// - Tags: ["ocean uploads"]
// It returns an error if the upload fails.
func UploadVideo(ctx context.Context, media io.Reader, opts ...VideoUploadOption) error {
const (
// Science & Technology category ID
scienceAndTechnologyCategoryID = "28"

// Defaults
defaultDescription = "No description provided."
defaultCategory = scienceAndTechnologyCategoryID
defaultPrivacy = "unlisted"
)

// Defaults
var (
defaultTitle = "Uploaded at " + time.Now().Format("2006-01-02 15:04:05")
defaultKeywords = []string{"ocean uploads"}
)

upload := &youtube.Video{
Snippet: &youtube.VideoSnippet{
Title: defaultTitle,
Description: defaultDescription,
CategoryId: defaultCategory,
Tags: defaultKeywords, // The API returns a 400 Bad Request response if tags is an empty string.
},
Status: &youtube.VideoStatus{PrivacyStatus: defaultPrivacy},
}

// Apply options
for _, opt := range opts {
if err := opt(upload); err != nil {
return fmt.Errorf("failed to apply option: %w", err)
}
}

// Force using the default account (AusOcean's account).
tokenURI := utils.TokenURIFromAccount("")
svc, err := broadcast.GetService(ctx, youtube.YoutubeScope, tokenURI)
if err != nil {
return fmt.Errorf("failed to get YouTube service: %w", err)
}

_, err = youtube.NewVideosService(svc).Insert([]string{"snippet", "status"}, upload).Media(media).Do()
if err != nil {
return fmt.Errorf("failed to insert video: %w", err)
}

return nil
}

// sanitiseCategory checks if the given category ID or Name is valid,
// and returns its ID if valid.
func sanitiseCategory(cat string) string {
categories := map[string]string{
"1": "Film & Animation",
"2": "Autos & Vehicles",
"10": "Music",
"15": "Pets & Animals",
"17": "Sports",
"18": "Short Movies",
"19": "Travel & Events",
"20": "Gaming",
"21": "Videoblogging",
"22": "People & Blogs",
"23": "Comedy",
"24": "Entertainment",
"25": "News & Politics",
"26": "Howto & Style",
"27": "Education",
"28": "Science & Technology",
"29": "Nonprofits & Activism",
"30": "Movies",
"31": "Anime/Animation",
"32": "Action/Adventure",
"33": "Classics",
"34": "Comedy",
"35": "Documentary",
"36": "Drama",
"37": "Family",
"38": "Foreign",
"39": "Horror",
"40": "Sci-Fi/Fantasy",
"41": "Thriller",
"42": "Shorts",
"43": "Shows",
"44": "Trailers",
}
for id, name := range categories {
if id == cat || name == cat {
return id
}
}
return ""
}

func validPrivacy(privacy string) bool {
validStatuses := map[string]bool{
"public": true,
"unlisted": true,
"private": true,
}
return validStatuses[privacy]
}
Loading