diff --git a/.forgejo/workflows/build-release.yaml b/.forgejo/workflows/build-release.yaml index 51a460a..2528be3 100644 --- a/.forgejo/workflows/build-release.yaml +++ b/.forgejo/workflows/build-release.yaml @@ -1,7 +1,7 @@ on: push: - branches: - - main + tags: + - 'v*' jobs: build-linux-amd64: @@ -24,3 +24,5 @@ jobs: with: direction: upload token: ${{ secrets.RELEASE_TOKEN }} + tag: ${{ env.GITHUB_REF_NAME }} + diff --git a/README.md b/README.md index b36d3c7..ff53e8f 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,13 @@ I built this to greatly simplify the process of getting my full-quality livestre $ vodular New config file created (config.toml). Please edit this file before running again! ``` +The directory which holds your configuration file and templates varies, +depending on platform: +- **Linux:** `~/.config/vodular/templates` +- **macOS:** `~/Library/Application Support/vodular/templates` +- **Windows:** `%AppData%/vodular/templates` -2. Edit configuration file as necessary (You will need to create a [YouTube Data API v3](https://developers.google.com/youtube/v3) service and provide its credentials here). +2. Edit your configuration file as necessary (You will need to create a [YouTube Data API v3](https://developers.google.com/youtube/v3) service and provide its credentials here). **IMPORTANT:** `config.toml` contains very sensitive credentials. Do not share this file with anyone. 3. Initialise a VOD directory: @@ -63,10 +68,11 @@ url = 'https://example.org' ``` ## Templates -Template files can be created at `templates/title.txt`, -`template/description.txt`, and `templates/tags.txt` respectively. These -files can use Go's [text template format](https://pkg.go.dev/text/template) to -customise VOD metadata on upload. +There are three template files, `title.txt`, `description.txt`, and `tags.txt`, +which can be created in `/path/to/vodular/templates`. These templates can be +created and tweaked to customise your VOD metadata on upload. They are enhanced +with Go's [template format](https://pkg.go.dev/text/template) to inject +information provided in `metadata.toml`, and other neat functionality! You can use the following data in templates: - **`.Title`:** The title of the stream. diff --git a/config/config.go b/config/config.go index a0fe6ee..28e0378 100644 --- a/config/config.go +++ b/config/config.go @@ -33,7 +33,7 @@ var defaultConfig = Config{ }, } -const CONFIG_FILENAME = "config.toml" +var CONFIG_FILENAME string = "config.toml" func ReadConfig(filename string) (*Config, error) { cfgBytes, err := os.ReadFile(filename) diff --git a/main.go b/main.go index 7c27390..341711a 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "log" + "math" "net/http" "os" "path" @@ -25,22 +26,34 @@ import ( //go:embed res/help.txt var helpText string -const segmentExtension = "mkv" +const SEGMENT_EXTENSION = "mkv" +const MAX_TITLE_LEN = 100 +const MAX_DESCRIPTION_LEN = 5000 func showHelp() { - execSplits := strings.Split(os.Args[0], "/") - execName := execSplits[len(execSplits) - 1] - fmt.Printf(helpText, execName) + fmt.Println(helpText) + os.Exit(0) } func main() { // config + userConfigDir, err := os.UserConfigDir() + if err != nil { + log.Fatalf("Could not determine user configuration directory: %v", err) + os.Exit(1) + } + 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) os.Exit(1) } if cfg == nil { + err = os.MkdirAll(path.Dir(config.CONFIG_FILENAME), 0750) + if err != nil { + log.Fatalf("Failed to create config directory: %v", err) + os.Exit(1) + } err = config.GenerateConfig(config.CONFIG_FILENAME) if err != nil { log.Fatalf("Failed to generate config: %v", err) @@ -57,7 +70,6 @@ func main() { // arguments if len(os.Args) < 2 || os.Args[1] == "--help" || os.Args[1] == "-h" { showHelp() - os.Exit(0) } var verbose bool = false @@ -76,7 +88,6 @@ func main() { fallthrough case "--help": showHelp() - os.Exit(0) case "-v": fallthrough @@ -143,7 +154,7 @@ func main() { } // good to have early on - templates, err := yt.FetchTemplates() + templates, err := yt.FetchTemplates(path.Join(userConfigDir, "vodular", "templates")) if err != nil { log.Fatalf("Failed to fetch templates: %v", err) os.Exit(1) @@ -185,7 +196,7 @@ func main() { } // scan for VOD segments - vodFiles, err := scanner.ScanSegments(metadata.FootageDir, segmentExtension) + vodFiles, err := scanner.ScanSegments(metadata.FootageDir, SEGMENT_EXTENSION) if err != nil { log.Fatalf("Failed to fetch VOD filenames: %v", err) os.Exit(1) @@ -193,7 +204,7 @@ func main() { if len(vodFiles) == 0 { log.Fatalf( "Directory contained no VOD files (expecting .%s)", - segmentExtension, + SEGMENT_EXTENSION, ) os.Exit(1) } @@ -212,35 +223,67 @@ func main() { log.Fatalf("Failed to build video template: %v", err) os.Exit(1) } + title, err := yt.BuildTemplate(video, templates.Title) + if err != nil { + log.Fatalf("Failed to build video title: %v", err) + os.Exit(1) + } + description, err := yt.BuildTemplate(video, templates.Description) + if err != nil { + log.Fatalf("Failed to build video description: %v", err) + os.Exit(1) + } + if len(title) > 100 { + log.Fatalf( + "Video title length exceeds %d characters (%d). YouTube may reject this!", + MAX_TITLE_LEN, + len(video.Title), + ) + } + if len(description) > 5000 { + log.Fatalf( + "Video description length exceeds %d characters (%d). YouTube may reject this!", + MAX_DESCRIPTION_LEN, + len(description), + ) + } if verbose { enc := json.NewEncoder(os.Stdout) fmt.Printf("\nVideo template: ") enc.Encode(video) - title, err := yt.BuildTemplate(video, templates.Title) - if err != nil { - log.Fatalf("Failed to build video title: %v", err) - os.Exit(1) - } - description, err := yt.BuildTemplate(video, templates.Description) - if err != nil { - log.Fatalf("Failed to build video description: %v", err) - os.Exit(1) - } fmt.Printf( - "\n================================\n" + - "TITLE:\n%s\n\n" + - "DESCRIPTION:\n%s\n" + + "\n================================\n\n" + + "< TITLE >\n%s\n\n" + + "< DESCRIPTION >\n%s\n" + "\n================================\n", title, description, ) } // concatenate VOD segments into full VOD - video.SizeBytes, err = vid.ConcatVideo(video, vodFiles, verbose) - if err != nil { - log.Fatalf("Failed to concatenate VOD segments: %v", err) - os.Exit(1) + fullVodExists := func () bool { + // check if full VOD already exists with expected duration + fullVodProbe, err := scanner.ProbeSegment(video.Filename) + if err != nil { return false } + video.SizeBytes = fullVodProbe.Format.Size + var totalLength float64 = 0 + + for _, filename := range vodFiles { + probe, err := scanner.ProbeSegment(path.Join(metadata.FootageDir, filename)) + if err != nil { continue } + totalLength += probe.Format.Duration + } + return math.Abs(fullVodProbe.Format.Duration - totalLength) < float64(0.1) + }() + if fullVodExists { + log.Print("Full VOD appears to already exist- uploading this file...") + } else { + video.SizeBytes, err = vid.ConcatVideo(video, vodFiles, verbose) + if err != nil { + log.Fatalf("Failed to concatenate VOD segments: %v", err) + os.Exit(1) + } } // youtube oauth flow @@ -290,6 +333,7 @@ func main() { log.Print("Video uploaded successfully!") // update metadata to reflect VOD is uploaded + // TODO: rather than a boolean flag, link to actual video metadata.Uploaded = true err = scanner.WriteMetadata(directory, metadata) if err != nil { diff --git a/scanner/scanner.go b/scanner/scanner.go index 86ea815..1ff5bd9 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -1,12 +1,15 @@ package scanner import ( + "encoding/json" "os" "path" + "strconv" "strings" "time" "github.com/pelletier/go-toml/v2" + ffmpeg_go "github.com/u2takey/ffmpeg-go" ) type ( @@ -25,6 +28,15 @@ type ( Uploaded bool `toml:"uploaded"` Category *Category `toml:"category" comment:"(Optional) Category details, for additional credits."` } + + FFprobeFormat struct { + Duration float64 `json:"duration"` + Size int64 `json:"size"` + } + + FFprobeOutput struct { + Format FFprobeFormat `json:"format"` + } ) const METADATA_FILENAME = "metadata.toml" @@ -39,6 +51,7 @@ func ScanSegments(directory string, extension string) ([]string, error) { for _, item := range entries { if item.IsDir() { continue } + if strings.HasPrefix(item.Name(), ".") { continue } if !strings.HasSuffix(item.Name(), "." + extension) { continue } if strings.HasSuffix(item.Name(), "-fullvod." + extension) { continue } files = append(files, item.Name()) @@ -47,6 +60,38 @@ func ScanSegments(directory string, extension string) ([]string, error) { return files, nil } +func ProbeSegment(filename string) (*FFprobeOutput, error) { + out, err := ffmpeg_go.Probe(filename) + if err != nil { return nil, err } + + type ( + RawFFprobeFormat struct { + // these being strings upsets me immensely + Duration string `json:"duration"` + Size string `json:"size"` + } + RawFFprobeOutput struct { + Format RawFFprobeFormat `json:"format"` + } + ) + + probe := RawFFprobeOutput{} + err = json.Unmarshal([]byte(out), &probe) + if err != nil { return nil, err } + + duration, err := strconv.ParseFloat(probe.Format.Duration, 64) + if err != nil { return nil, err } + size, err := strconv.ParseInt(probe.Format.Size, 10, 0) + if err != nil { return nil, err } + + return &FFprobeOutput{ + Format: FFprobeFormat{ + Duration: duration, + Size: size, + }, + }, nil +} + func ReadMetadata(directory string) (*Metadata, error) { metadata := &Metadata{} file, err := os.OpenFile( diff --git a/youtube/youtube.go b/youtube/youtube.go index 79641fa..c6ace15 100644 --- a/youtube/youtube.go +++ b/youtube/youtube.go @@ -113,11 +113,6 @@ var videoYtCategory = map[CategoryType]string { CATEGORY_ENTERTAINMENT: YT_CATEGORY_ENTERTAINMENT, } -var templateDir = "templates" -var tagsPath = path.Join(templateDir, "tags.txt") -var titlePath = path.Join(templateDir, "title.txt") -var descriptionPath = path.Join(templateDir, "description.txt") - const defaultTitleTemplate = "{{.Title}} - {{FormatTime .Date \"02 Jan 2006\"}}" const defaultDescriptionTemplate = @@ -135,9 +130,13 @@ var templateFuncs = template.FuncMap{ }, } -func FetchTemplates() (*Template, error) { +func FetchTemplates(templateDir string) (*Template, error) { tmpl := Template{} + var tagsPath = path.Join(templateDir, "tags.txt") + var titlePath = path.Join(templateDir, "title.txt") + var descriptionPath = path.Join(templateDir, "description.txt") + // tags if tagsFile, err := os.ReadFile(tagsPath); err == nil { tmpl.Tags = strings.Split(string(tagsFile), "\n")