first working version!

This commit is contained in:
ari melody 2026-01-28 10:48:14 +00:00
parent 1bba7ef03d
commit 84de96df31
Signed by: ari
GPG key ID: CF99829C92678188
9 changed files with 689 additions and 80 deletions

273
main.go
View file

@ -4,11 +4,19 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"path"
"strings"
toml "github.com/pelletier/go-toml/v2"
"google.golang.org/api/option"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/youtube/v3"
"arimelody.space/live-vod-uploader/scanner"
vid "arimelody.space/live-vod-uploader/video"
yt "arimelody.space/live-vod-uploader/youtube"
)
type (
@ -23,99 +31,210 @@ type (
}
)
var DEFAULT_TAGS = []string{
"ari melody",
"ari melody LIVE",
"livestream",
"vtuber",
"twitch",
"gaming",
"let's play",
"full VOD",
"VOD",
"stream",
"archive",
}
const CONFIG_FILENAME = "config.toml"
const (
CATEGORY_GAMING = "20"
)
func showHelp() {
execSplits := strings.Split(os.Args[0], "/")
execName := execSplits[len(execSplits) - 1]
fmt.Printf(
"usage: %s [options] [directory]\n\n" +
"options:\n" +
"\t-h, --help: Show this help message.\n" +
"\t-v, --verbose: Show verbose logging output.\n" +
"\t--init: Initialise `directory` as a VOD directory.\n",
execName)
}
func main() {
if len(os.Args) < 2 {
fmt.Printf("usage: %s <video ID>\n", os.Args[0])
if len(os.Args) < 2 || os.Args[1] == "--help" || os.Args[1] == "-h" {
showHelp()
os.Exit(0)
}
// videoID := os.Args[1]
var directory string
var initDirectory bool = false
var verbose bool = false
cfgBytes, err := os.ReadFile("config.toml")
if err != nil {
fmt.Fprintf(os.Stderr, "fatal: failed to read config file: %s\n", err.Error())
os.Exit(1)
for i, arg := range os.Args {
if i == 0 { continue }
if strings.HasPrefix(arg, "-") {
switch arg {
case "-h":
fallthrough
case "--help":
showHelp()
os.Exit(0)
case "--init":
initDirectory = true
case "-v":
fallthrough
case "--verbose":
verbose = true
default:
fmt.Fprintf(os.Stderr, "Unknown option `%s`\n", arg)
os.Exit(1)
}
} else {
directory = arg
}
}
cfg := Config{}
cfgBytes, err := os.ReadFile(CONFIG_FILENAME)
if err != nil {
log.Fatalf("Failed to read config file: %v", err)
tomlBytes, err := toml.Marshal(&cfg)
if err != nil {
log.Fatalf("Failed to marshal json: %v", err)
os.Exit(1)
}
err = os.WriteFile(CONFIG_FILENAME, tomlBytes, 0o644)
if err != nil {
log.Fatalf("Failed to write config file: %v", err)
os.Exit(1)
}
log.Printf("New config file created. Please edit this before running again!")
os.Exit(0)
}
err = toml.Unmarshal(cfgBytes, &cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "fatal: failed to parse config: %s\n", err.Error())
log.Fatalf("Failed to parse config: %v", err)
os.Exit(1)
}
if initDirectory {
dirInfo, err := os.Stat(directory)
if err != nil {
if err == os.ErrNotExist {
log.Fatalf("No such directory: %s", directory)
os.Exit(1)
}
log.Fatalf("Failed to open directory: %v", err)
os.Exit(1)
}
if !dirInfo.IsDir() {
log.Fatalf("Not a directory: %s", directory)
os.Exit(1)
}
dirEntry, err := os.ReadDir(directory)
if err != nil {
log.Fatalf("Failed to open directory: %v", err)
os.Exit(1)
}
for _, entry := range dirEntry {
if !entry.IsDir() && entry.Name() == "metadata.toml" {
log.Printf("Directory `%s` already initialised", directory)
os.Exit(0)
return
}
defaultMetadata := scanner.DefaultMetadata()
metadataStr, _ := toml.Marshal(defaultMetadata)
err = os.WriteFile(path.Join(directory, "metadata.toml"), metadataStr, 0o644)
if err != nil {
log.Fatalf("Failed to write to file: %v", err)
os.Exit(1)
}
log.Printf("Directory successfully initialised")
os.Exit(0)
}
}
metadata, err := scanner.FetchMetadata(directory)
if err != nil {
log.Fatalf("Failed to fetch VOD metadata: %v", err)
os.Exit(1)
}
if metadata == nil {
log.Fatal("Directory contained no metadata. Use `--init` to initialise this directory.")
os.Exit(1)
}
vodFiles, err := scanner.FetchVideos(metadata.FootageDir)
if err != nil {
log.Fatalf("Failed to fetch VOD filenames: %v", err)
os.Exit(1)
}
if len(vodFiles) == 0 {
log.Fatal("Directory contained no VOD files (expecting .mkv)")
os.Exit(1)
}
if verbose {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", "\t")
fmt.Printf("Directory metadata: ")
enc.Encode(metadata)
fmt.Printf("\nVOD files available: ")
enc.Encode(vodFiles)
}
video, err := yt.BuildVideo(metadata)
if err != nil {
log.Fatalf("Failed to build video template: %v", err)
os.Exit(1)
}
if verbose {
enc := json.NewEncoder(os.Stdout)
fmt.Printf("\nVideo template: ")
enc.Encode(video)
title, err := yt.BuildTitle(video)
if err != nil {
log.Fatalf("Failed to build video title: %v", err)
os.Exit(1)
}
description, err := yt.BuildDescription(video)
if err != nil {
log.Fatalf("Failed to build video description: %v", err)
os.Exit(1)
}
fmt.Printf(
"\nTITLE: %s\nDESCRIPTION: %s",
title, description,
)
}
err = vid.ConcatVideo(video, vodFiles)
if err != nil {
log.Fatalf("Failed to concatenate VOD files: %v", err)
os.Exit(1)
}
// okay actual youtube stuff now
// TODO: tidy up oauth flow with localhost webserver
ctx := context.Background()
service, err := youtube.NewService(
ctx,
option.WithScopes(youtube.YoutubeUploadScope),
option.WithAPIKey(cfg.Google.ApiKey),
)
config := &oauth2.Config{
ClientID: cfg.Google.ClientID,
ClientSecret: cfg.Google.ClientSecret,
Endpoint: google.Endpoint,
Scopes: []string{ youtube.YoutubeScope },
RedirectURL: "http://localhost:8090",
}
verifier := oauth2.GenerateVerifier()
url := config.AuthCodeURL("state", oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier))
log.Printf("Visit URL to initiate OAuth2: %s", url)
var code string
fmt.Print("Enter OAuth2 code: ")
if _, err := fmt.Scan(&code); err != nil {
log.Fatalf("Failed to read oauth2 code: %v", err)
}
token, err := config.Exchange(ctx, code, oauth2.VerifierOption(verifier))
log.Printf("Token expires on %s\n", token.Expiry.Format("02 Jan 2006"))
if err != nil {
fmt.Fprintf(os.Stderr, "fatal: failed to create youtube service: %s\n", err.Error())
log.Fatalf("Could not exchange OAuth2 code: %v", err)
os.Exit(1)
}
videoService := youtube.NewVideosService(service)
// get video by ID
{
// call := service.Videos.List([]string{
// "snippet", "contentDetails", "statistics", "status",
// }).Id(videoID)
// res, err := call.Do()
// if err != nil {
// fmt.Fprintf(os.Stderr, "fatal: failed to request videos list: %s\n", err.Error())
// os.Exit(1)
// }
// data, err := json.MarshalIndent(res, "", " ")
// if err != nil {
// fmt.Fprintf(os.Stderr, "fatal: failed to marshal json: %s\n", err.Error())
// os.Exit(1)
// }
// fmt.Println(string(data))
}
call := videoService.Insert([]string{
"snippet", "status",
}, &youtube.Video{
Snippet: &youtube.VideoSnippet{
Title: "Untitled Video",
Description: "No description",
Tags: DEFAULT_TAGS,
CategoryId: CATEGORY_GAMING, // gaming
},
}).NotifySubscribers(false)
// TODO: call.Media()
video, err := call.Do()
if err != nil {
fmt.Fprintf(os.Stderr, "fatal: failed to upload video: %s\n", err.Error())
os.Exit(1)
}
data, err := json.MarshalIndent(video, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "fatal: failed to marshal video data json: %s\n", err.Error())
os.Exit(1)
}
fmt.Println(string(data))
yt.UploadVideo(ctx, token, video)
}