vodular/main.go

414 lines
11 KiB
Go
Raw Normal View History

2025-11-05 21:22:15 +00:00
package main
import (
"context"
2026-01-28 12:50:11 +00:00
_ "embed"
2025-11-05 21:22:15 +00:00
"encoding/json"
"fmt"
2026-01-28 10:48:14 +00:00
"log"
"math"
2025-11-05 21:22:15 +00:00
"os"
2026-01-28 10:48:14 +00:00
"path"
"strings"
2025-11-05 21:22:15 +00:00
2026-01-28 10:48:14 +00:00
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/option"
2025-11-05 21:22:15 +00:00
"google.golang.org/api/youtube/v3"
2026-01-28 10:48:14 +00:00
2026-01-30 18:53:09 +00:00
"arimelody.space/vodular/config"
2026-06-28 13:40:08 +01:00
"arimelody.space/vodular/oauth"
2026-01-30 18:53:09 +00:00
"arimelody.space/vodular/scanner"
vid "arimelody.space/vodular/video"
yt "arimelody.space/vodular/youtube"
2025-11-05 21:22:15 +00:00
)
2026-01-28 12:50:11 +00:00
//go:embed res/help.txt
var helpText string
2026-01-28 10:48:14 +00:00
const SEGMENT_EXTENSION = "mkv"
const MAX_TITLE_LEN = 100
const MAX_DESCRIPTION_LEN = 5000
2025-11-05 21:22:15 +00:00
func main() {
ctx := context.Background()
2026-01-28 10:48:14 +00:00
var verbose bool = false
2026-01-28 12:50:11 +00:00
var initDirectory bool = false
2026-01-30 18:53:09 +00:00
var logout bool = false
2026-06-28 16:37:42 +01:00
var keepConcatVOD bool = false
2026-01-28 12:50:11 +00:00
var forceUpload bool = false
2026-06-28 13:35:06 +01:00
var dryRun bool = false
2026-06-28 16:37:42 +01:00
var directory string = ""
2026-01-28 10:48:14 +00:00
for i, arg := range os.Args {
if i == 0 { continue }
if strings.HasPrefix(arg, "-") {
switch arg {
case "-h":
fallthrough
case "--help":
2026-06-28 16:37:42 +01:00
log.Fatal(helpText)
2026-01-28 10:48:14 +00:00
case "-v":
fallthrough
case "--verbose":
verbose = true
2026-06-28 16:37:42 +01:00
case "--dry-run":
dryRun = true
2026-01-28 12:50:11 +00:00
case "-f":
fallthrough
case "--force":
forceUpload = true
2026-06-28 16:37:42 +01:00
case "--init":
initDirectory = true
case "--keep-vod":
keepConcatVOD = true
case "--logout":
logout = true
2026-06-28 13:35:06 +01:00
2026-01-28 10:48:14 +00:00
default:
fmt.Fprintf(os.Stderr, "Unknown option `%s`\n", arg)
os.Exit(1)
}
} else {
directory = arg
}
}
2025-11-05 21:22:15 +00:00
2026-06-28 16:59:41 +01:00
// config
userConfigDir, err := os.UserConfigDir()
if err != nil {
log.Fatalf("Could not determine user configuration directory: %v", err)
}
config.CONFIG_FILENAME = path.Join(userConfigDir, "vodular", "config.toml")
cfg, err := config.ReadConfig(config.CONFIG_FILENAME)
if err != nil {
log.Fatalf("Failed to read config: %v", err)
}
if cfg == nil {
if err = os.MkdirAll(path.Dir(config.CONFIG_FILENAME), 0750); err != nil {
log.Fatalf("Failed to create config directory: %v", err)
}
if err = config.GenerateConfig(config.CONFIG_FILENAME); err != nil {
log.Fatalf("Failed to generate config: %v", err)
}
log.Printf(
"New config file created (%s). " +
"Please edit this file before running again!",
config.CONFIG_FILENAME,
)
os.Exit(0)
}
oauthTokenFilepath := path.Join(userConfigDir, "vodular", "youtube-auth.json")
var token *oauth2.Token
if data, err := os.ReadFile(oauthTokenFilepath); err != nil {
if !os.IsNotExist(err) {
log.Fatalf("Failed to open %s: %v", oauthTokenFilepath, err)
}
} else {
if err := json.Unmarshal(data, &token); err != nil {
log.Fatalf("Failed to parse YouTube auth file: %v", err)
}
}
if !strings.HasPrefix(directory, "/") {
if wd, err := os.Getwd(); err != nil {
directory = path.Join(directory, wd)
}
}
2026-01-30 18:53:09 +00:00
// logout (--logout)
if logout {
2026-06-28 16:59:41 +01:00
if token == nil { log.Fatal("Not logged in.") }
if err := os.Remove(oauthTokenFilepath); err != nil {
log.Fatalf("Failed to delete %s: %v", oauthTokenFilepath, err)
}
2026-06-28 16:37:42 +01:00
log.Fatalf("Logged out successfully.")
2026-01-28 12:50:11 +00:00
}
2025-11-05 21:22:15 +00:00
2026-01-28 12:50:11 +00:00
// initialising directory (--init)
2026-01-28 10:48:14 +00:00
if initDirectory {
2026-06-28 16:59:41 +01:00
if err = scanner.InitialiseDirectory(directory); err != nil {
2026-01-28 12:50:11 +00:00
log.Fatalf("Failed to initialise directory: %v", err)
2026-01-28 10:48:14 +00:00
}
log.Fatalf(
"Directory successfully initialised. " +
"Be sure to update %s before uploading!",
scanner.METADATA_FILENAME,
)
2026-01-28 10:48:14 +00:00
}
2026-06-28 16:59:41 +01:00
2026-01-30 18:53:09 +00:00
// good to have early on
2026-01-31 03:10:28 +00:00
templates, err := yt.FetchTemplates(path.Join(userConfigDir, "vodular", "templates"))
2026-01-30 18:53:09 +00:00
if err != nil {
log.Fatalf("Failed to fetch templates: %v", err)
}
2026-01-28 12:50:11 +00:00
// read directory metadata
metadata, err := scanner.ReadMetadata(directory)
2025-11-05 21:22:15 +00:00
if err != nil {
if os.IsNotExist(err) {
log.Fatalf("Directory does not exist: %s", directory)
}
2026-01-28 10:48:14 +00:00
log.Fatalf("Failed to fetch VOD metadata: %v", err)
}
if metadata == nil {
2026-01-28 12:50:11 +00:00
log.Fatal(
"Directory contained no metadata. " +
"Use `--init` to initialise this directory.",
)
}
// default footage directory
footageDir := directory
if len(metadata.FootageDir) >= 0 && !strings.HasPrefix(metadata.FootageDir, "/") {
footageDir = path.Join(directory, metadata.FootageDir)
}
2026-01-28 12:50:11 +00:00
// scan for VOD segments
vodFiles, err := scanner.ScanSegments(footageDir, SEGMENT_EXTENSION)
2026-01-28 10:48:14 +00:00
if err != nil {
log.Fatalf("Failed to fetch VOD filenames: %v", err)
}
if len(vodFiles) == 0 {
2026-01-28 12:50:11 +00:00
log.Fatalf(
"Directory contained no VOD files (expecting .%s)",
SEGMENT_EXTENSION,
2026-01-28 12:50:11 +00:00
)
2025-11-05 21:22:15 +00:00
}
2026-01-28 10:48:14 +00:00
if verbose {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", "\t")
log.Print("[ Metadata ]")
log.Printf("Title: %s", metadata.Title)
log.Printf("Part: %d", metadata.Part)
log.Printf("Date: %s", metadata.Date.String())
log.Printf(
"Category: `%s` - %s (%s)",
metadata.Category.Type,
metadata.Category.Name,
metadata.Category.Url,
)
log.Printf("Tags: %s", strings.Join(metadata.Tags, ", "))
log.Printf("VOD files: [ %s ]", strings.Join(vodFiles, ", "))
2026-01-28 10:48:14 +00:00
}
// scan for thumbnail
var thumbnail *yt.ThumbnailMetadata
thumbnailPath, thumbnailSizeBytes, err := scanner.ScanThumbnail(directory)
if err != nil {
log.Fatalf("Failed to fetch thumbnail: %v", err)
2026-06-23 19:07:51 +01:00
} else {
thumbnail = &yt.ThumbnailMetadata{
2026-06-23 19:07:51 +01:00
Filepath: thumbnailPath,
SizeBytes: thumbnailSizeBytes,
}
}
2026-01-28 12:50:11 +00:00
// build video template for upload
videoMeta, err := yt.BuildVideo(metadata)
2025-11-05 21:22:15 +00:00
if err != nil {
2026-01-28 10:48:14 +00:00
log.Fatalf("Failed to build video template: %v", err)
2025-11-05 21:22:15 +00:00
}
title, err := yt.BuildTemplate(videoMeta, templates.Title)
if err != nil {
log.Fatalf("Failed to build video title: %v", err)
}
description, err := yt.BuildTemplate(videoMeta, templates.Description)
if err != nil {
log.Fatalf("Failed to build video description: %v", err)
}
if len(title) > 100 {
log.Fatalf(
"Video title length exceeds %d characters (%d). YouTube may reject this!",
MAX_TITLE_LEN,
len(videoMeta.Title),
)
}
if len(description) > 5000 {
log.Fatalf(
"Video description length exceeds %d characters (%d). YouTube may reject this!",
MAX_DESCRIPTION_LEN,
len(description),
)
}
2026-01-28 10:48:14 +00:00
if verbose {
log.Print()
log.Printf("[ TITLE ]\n\n%s\n\n", title)
log.Printf("[ DESCRIPTION ]\n\n%s\n\n", description)
2026-01-28 10:48:14 +00:00
}
2026-06-28 16:59:41 +01:00
2026-01-28 12:50:11 +00:00
// youtube oauth flow
oauth2Config := &oauth2.Config{
ClientID: cfg.Google.ClientID,
ClientSecret: cfg.Google.ClientSecret,
Endpoint: google.Endpoint,
Scopes: []string{ youtube.YoutubeScope },
RedirectURL: cfg.RedirectUri,
}
2026-06-28 16:59:41 +01:00
if token == nil {
2026-06-28 13:40:08 +01:00
token, err = oauth.GenerateToken(&ctx, oauth2Config, cfg)
2026-06-28 16:59:41 +01:00
if err != nil { log.Fatalf("OAuth flow failed: %v", err) }
tokenFile, err := os.OpenFile(oauthTokenFilepath, os.O_RDWR | os.O_CREATE, 0600)
if err != nil { log.Fatalf("Failed to open %s: %v", oauthTokenFilepath, err) }
if err := json.NewEncoder(tokenFile).Encode(token); err != nil {
log.Fatalf("Failed to write %s: %v", oauthTokenFilepath, err)
}
}
2026-06-28 16:59:41 +01:00
tokenSource := oauth2Config.TokenSource(ctx, token)
if err != nil {
log.Fatalf("Failed to create OAuth2 token source: %v", err)
}
if err = config.WriteConfig(cfg, config.CONFIG_FILENAME); err != nil {
log.Fatalf("Failed to save OAuth token: %v", err)
}
2026-06-28 16:59:41 +01:00
service, err := youtube.NewService(
ctx,
option.WithScopes(youtube.YoutubeUploadScope),
option.WithTokenSource(tokenSource),
)
if err != nil {
log.Fatalf("Failed to create youtube service: %v\n", err)
2026-01-28 12:50:11 +00:00
}
2026-06-28 16:59:41 +01:00
// skip uploading if already done
if (metadata.Uploaded || len(metadata.UploadURL) > 0) && !forceUpload {
if len(metadata.UploadURL) > 0 {
log.Printf("VOD has already been uploaded: %s", metadata.UploadURL)
} else {
log.Println("VOD has already been uploaded.")
}
if !dryRun {
log.Printf(
"To upload anyways, use --force or update %s.",
scanner.METADATA_FILENAME,
)
}
if len(metadata.UploadURL) > 0 {
if !dryRun {
videoId := strings.TrimPrefix(metadata.UploadURL, yt.VIDEO_URL_BASE)
if videoId == metadata.UploadURL {
log.Fatal("can't parse video ID from upload URL")
}
log.Println("Updating metadata...")
if err = yt.UpdateMetadata(ctx, tokenSource, service, videoMeta, videoId, templates); err != nil {
log.Printf("update metadata: %v", err)
}
log.Println("Uploading thumbnail...")
if err = yt.UploadThumbnail(ctx, tokenSource, service, videoId, thumbnail); err != nil {
log.Printf("upload thumbnail: %v", err)
}
} else {
log.Print("Dry run: Skipping metadata update")
}
}
os.Exit(0)
}
2026-06-28 16:59:41 +01:00
videoMeta.Filepath = path.Join(metadata.FootageDir, vodFiles[0])
// no need to concat if VOD is already one whole file
if len(vodFiles) > 1 {
videoMeta.Filepath = path.Join(
metadata.FootageDir,
fmt.Sprintf("%s-fullvod.mkv", metadata.Date.String()),
)
if !dryRun {
// check if full VOD already exists with expected duration
concatVodExists := false
concatVodProbe, err := scanner.ProbeSegment(videoMeta.Filepath)
if err == nil {
videoMeta.SizeBytes = concatVodProbe.Format.Size
var totalLength float64 = 0
for _, filename := range vodFiles {
probe, err := scanner.ProbeSegment(path.Join(footageDir, filename))
if err != nil { continue }
totalLength += probe.Format.Duration
}
// full VOD may exist, but not be complete (interrupted concat)
concatVodExists = math.Abs(concatVodProbe.Format.Duration - totalLength) < float64(0.1)
}
if concatVodExists {
log.Print("Full VOD appears to already exist- uploading this file...")
} else {
// concatenate VOD segments into full VOD
videoMeta.SizeBytes, err = vid.ConcatVideo(videoMeta, vodFiles, verbose)
if err != nil {
log.Fatalf("Failed to concatenate VOD segments: %v", err)
}
}
} else {
log.Print("Dry run: Skipping VOD concatenation")
}
}
2026-06-28 16:59:41 +01:00
if !dryRun {
2026-06-28 13:35:06 +01:00
// okay actually upload now!
ytVideo, err := yt.UploadVideo(ctx, tokenSource, service, videoMeta, templates)
2026-01-28 12:50:11 +00:00
if err != nil {
2026-06-28 13:35:06 +01:00
log.Fatalf("Failed to upload video: %v", err)
2026-01-28 12:50:11 +00:00
}
2026-06-28 13:35:06 +01:00
if verbose {
jsonString, err := json.MarshalIndent(ytVideo, "", " ")
if err != nil {
log.Fatalf("Failed to marshal video data json: %v", err)
}
fmt.Println(string(jsonString))
}
log.Print("Video uploaded successfully!")
2026-01-28 12:50:11 +00:00
// set the thumbnail
log.Println("Uploading thumbnail...")
if err = yt.UploadThumbnail(ctx, tokenSource, service, ytVideo.Id, thumbnail); err != nil {
log.Printf("Failed to upload thumbnail: %v", err)
}
2026-06-28 13:35:06 +01:00
// update metadata to reflect VOD is uploaded
metadata.UploadURL = yt.VIDEO_URL_BASE + ytVideo.Id
2026-06-28 16:59:41 +01:00
if err = scanner.WriteMetadata(directory, metadata); err != nil {
2026-06-28 13:35:06 +01:00
log.Fatalf("Failed to update metadata: %v", err)
}
2026-06-28 16:37:42 +01:00
// delete concatenated VOD after upload, unless specified otherwise
// if len(vodFiles) == 1, the full VOD is the *only* VOD. do not delete this!!!
2026-06-28 16:37:42 +01:00
if len(vodFiles) > 1 && !keepConcatVOD {
if err = os.Remove(videoMeta.Filepath); err != nil {
2026-06-28 13:35:06 +01:00
log.Fatalf("Failed to delete full VOD: %v", err)
}
2026-01-28 12:50:11 +00:00
}
2026-06-28 13:35:06 +01:00
} else {
log.Print("Dry run: Skipping video upload")
2026-01-28 12:50:11 +00:00
}
}