I’ve had a fascination with GoLang ever since I heard about it. Especially after my first project using it, triangular arbitrage bot, I’ve been looking for an excuse to use it for something else.
Something I find myself doing often on the comand line at home is using the amazing yt-dlp project to download music and vods from YouTube and Soundcloud. yt-dlp is a flexible tool for downloading various content types from the web. It’s amazing.. Almost too amazing…
The thing is, this project has so many flags and options that I get overwhelmed. After I figured out what long command gets me my media exactly as I want, I saved it to a doc and whenever I want to dl something else, I awkwardly copy it manually form the doc and paste it into my terminal.
Then I take my media file, say a song from soundcloud, and place it into a specific folder structure for my Plex instance to pick up correctly in PlexAmp.
”Couldnt you just make a bash script?”
Well yeah sure, but simplicity isn’t the point right now! I want to build something cooler!
You’ve likely heard of Cobra as it is the Go CLI framework. For most projects, cobra is an obvious choice.
I’ll use the cobra-cli to bootstrap the project with viper to manage configs later:
cobra-cli init --author "Matt Creekmore [email protected]" --viper
And we can get started by adding our first command:
cobra-cli add soundcloud
If this project is to provide people convenience, we’re going to need a way to embed binaries into the project so that users don’t have to install them seperately as a prerequisite. With Go 1.16, the native embed directive was added that looks perfect for what we need.
Let’s create an embed
package so that we can re-use our binary around our app.
We’ll also need to include ffmpeg
for any post-processing needs. Our module ends up looking like this:
package embed
import (
_ "embed"
"fmt"
"os"
"os/exec"
)
//go:embed yt-dlp
var ytDlpBinary []byte
//go:embed ffmpeg
var ffmpegBinary []byte
// ExecuteYtDlp executes yt-dlp from the embedded binary
func ExecuteYtDlp(args []string) {
// Create a temporary directory
tempDir, err := os.MkdirTemp("", "yt-dlp-embed")
if err != nil {
fmt.Printf("Error creating temporary directory: %v\n", err)
return
}
defer os.RemoveAll(tempDir)
// Write embedded yt-dlp binary to a temporary file
ytDlpBinaryPath := tempDir + "/yt-dlp"
err = os.WriteFile(ytDlpBinaryPath, ytDlpBinary, 0755)
if err != nil {
fmt.Printf("Error writing yt-dlp binary to temporary location: %v\n", err)
return
}
// Write embedded ffmpeg binary to a temporary file
ffmpegBinaryPath := tempDir + "/ffmpeg"
err = os.WriteFile(ffmpegBinaryPath, ffmpegBinary, 0755)
if err != nil {
fmt.Printf("Error writing ffmpeg binary to temporary location: %v\n", err)
return
}
// Append ffmpeg path to args
args = append(args, "--ffmpeg-location", ffmpegBinaryPath)
// Execute yt-dlp from the temporary location
cmd := exec.Command(ytDlpBinaryPath, args...)
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Println(string(output))
fmt.Printf("Error executing yt-dlp command: %v\n", err)
return
}
fmt.Println(string(output))
}
Now we’re free to import this anywhere around the project:
embed.ExecuteYtDlp(args)
When it comes time to reading configs like for where to download our music, it couldn’t get any easier.
Viper makes it as simple as:
musicDir := viper.GetString("music_directory")
By default reading a config file from $HOME/.godlp.yaml
, and optionally can pass
one with --config /path/to/config.yaml
First up is the soundcloud
command.
This ended up being fairly simple. We pass our arguments along with the provided url
to our inbeded yt-dlp and tell it to download them to a /temp
directory:
tempDir := cwd + "/temp"
scArgs := []string{
"-o", "%(title)s.%(ext)s",
"--embed-metadata",
"--embed-thumbnail",
"--metadata-from-title", "%(album)s",
"--paths", tempDir,
args[0], // Assuming soundcloudURL is the first argument
}
embed.ExecuteYtDlp(scArgs)
yt-dlp doesn’t exactly have a real way to take the Album information and embed it
in the metadata. our --metadata-from-title
flag copies the title into the albumn field.
Not exactly what we want, so I added an --album
flag and a helper function to apply it
to the files with ffmpeg
:
albumName, _ := cmd.Flags().GetString("album")
if albumName == "" {
fmt.Println("No album name provided. Grabbing Artist name instead.")
artistName, err := utils.ExtractArtistNameFromFile(tempDir)
if err != nil {
fmt.Printf("Error extracting artist name: %v\n", err)
return
}
albumName = artistName
} else {
fmt.Println("Album flag provided. Writing with ffmpeg...")
utils.ChangeAlbumNameWithFFmpeg(tempDir, albumName)
}
For Plex organization, the album name is used as the final save directory.
Now that we have our completed files, its as simple as moving them to our
music_directory
and clearing our /temp
directory!
Implementing a youtube
command is even simpler. We only need a video_directory
in our config and a url we want to download.
In our command, we’ll pass the arguments as the equivalent of this command:
yt-dlp -o '%(title)s.%(ext)s' URL
This just removes the ID from the filename to make it cleaner. Then all we have to do is move the file. Done!
I have many more features planned but at the very least, I need to be able to build and distribute my app. Thankfully goreleaser exists and does everything I’m looking for.
We can define a .goreleaser.yaml
and tell it what platforms we’d like to target:
builds:
- binary: godlp
goos:
- darwin
- linux
goarch:
- amd64
- arm64
env:
- CGO_ENABLED=0
flags:
- -mod=vendor
release:
prerelease: auto
universal_binaries:
- replace: true
checksum:
name_template: 'checksums.txt'
For now, we’re just targeting mac and linux. The -mod=vendor
flag allows us to keep a copy of all of our depencenies in our repo just in case any of them become unavailable in the future.
We’ll use GitHub Actions to actually run our builds. That can be done by defining a .github/workflows/release.yaml
. Goreleaser comes with many good defaults, so we only need to specify the following:
name: goreleaser
on:
push:
tags:
- '*'
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: stable
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v5
with:
distribution: goreleaser
version: ${{env.GITHUB_REF_NAME}}
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.PUBLISHER_TOKEN}}
And thats it! Whenever we push to master
with a tag, it will trigger our build pipeline.
Well… not quite yet. There’s something we’ve got to address.
If we were to run our builds in this current state, we’d only have one platform that actually works. This is because we don’t just have our go code to worry about platform-specific builds. We’ve also got binaries we’re dependent on.
We’re going to need darwin
and linux
versions of both yt-dlp
and ffmpeg
binaries. Windows will come soon.
We can use the go file suffixes to make multiple versions of our godlp/embed
package and go will use the GOOS
env variable to determine which file to use. Our embed directory will then look like this:
.
├── embed_darwin.go
├── embed_linux.go
├── ffmpeg_darwin
├── ffmpeg_linux
├── yt-dlp_darwin
└── yt-dlp_linux
Now one of our embed files looks like this :
package embed
import (
_ "embed"
)
//go:embed yt-dlp_darwin
var YtDlpBinary []byte
//go:embed ffmpeg_darwin
var FfmpegBinary []byte
I then moved the Execute functions into the utils package since they make more sense there.
And now we’re finally done! Platform-specific builds are that simple.
Now one last thing to really make this project seem legit:
To add this package to the homebrew
package manager, lets set up a tap
(or repo) for brew to know how to access my builds:
brew tap-new mcreekmore/homebrew-mcreekmore
Then, I just set up that same repo on github and add it as my remote origin and add the following to our .goreleaser.yaml
:
brews:
- name: godlp
folder: Formula
homepage: https://github.com/mcreekmore/godlp
description: 'Convenient wrapper for yt-dlp'
repository:
owner: mcreekmore
name: homebrew-mcreekmore
commit_author:
name: mcreekmore
email: [email protected]
Now I can push a tag to my the repo and it will build:
git tag -a v0.1.0 -m "First build"
git push origin v0.1.0
Finally, I can simply add the tap I created and download my package!
brew tap mcreekmore/mcreekmore
brew install godlp
And it’s ready to be played in PlexAmp :)