Add audio-only downloads

This commit is contained in:
Carl Pearson
2024-09-12 17:10:21 -06:00
parent 648ebdd424
commit f8a182aa75
9 changed files with 375 additions and 89 deletions

View File

@@ -2,3 +2,4 @@ data
config config
go.sum go.sum
*.mp4 *.mp4
*.m4a

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
config config
go.sum go.sum
*.mp4 *.mp4
*.m4a
data data

View File

@@ -18,7 +18,8 @@ go run *.go
## Docker ## Docker
```bash ```bash
docker build -t server . docker build --build-arg GIT_SHA=$(git rev-parse HEAD) \
-t server .
docker run --rm -it \ docker run --rm -it \
-p 3000:8080 \ -p 3000:8080 \

View File

@@ -2,7 +2,6 @@ package main
import ( import (
"bytes" "bytes"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
@@ -14,6 +13,7 @@ import (
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
) )
func registerHandler(c echo.Context) error { func registerHandler(c echo.Context) error {
@@ -89,22 +89,38 @@ func downloadHandler(c echo.Context) error {
func downloadPostHandler(c echo.Context) error { func downloadPostHandler(c echo.Context) error {
url := c.FormValue("url") url := c.FormValue("url")
userID := c.Get("user_id").(uint) userID := c.Get("user_id").(uint)
vaStr := c.FormValue("color")
original := Original{URL: url, UserID: userID, Status: "pending"} audioOnly := false
if vaStr == "audio" {
audioOnly = true
} else if vaStr == "audio-video" {
audioOnly = false
} else {
return c.Redirect(http.StatusSeeOther, "/download")
}
original := Original{
URL: url,
UserID: userID,
Status: "pending",
Audio: audioOnly,
Video: !audioOnly,
}
db.Create(&original) db.Create(&original)
go startDownload(original.ID, url) go startDownload(original.ID, url, audioOnly)
return c.Redirect(http.StatusSeeOther, "/videos") return c.Redirect(http.StatusSeeOther, "/videos")
} }
type Meta struct { type Meta struct {
title string title string
artist string
ext string ext string
} }
func getMeta(url string) (Meta, error) { func getYtdlpTitle(url string, args []string) (string, error) {
ytdlp := "yt-dlp" ytdlp := "yt-dlp"
args := []string{"--simulate", "--print", "%(title)s.%(ext)s", url} args = append(args, "--simulate", "--print", "%(title)s", url)
fmt.Println(ytdlp, strings.Join(args, " ")) fmt.Println(ytdlp, strings.Join(args, " "))
cmd := exec.Command(ytdlp, args...) cmd := exec.Command(ytdlp, args...)
@@ -112,22 +128,73 @@ func getMeta(url string) (Meta, error) {
cmd.Stdout = &stdout cmd.Stdout = &stdout
err := cmd.Run() err := cmd.Run()
if err != nil { if err != nil {
fmt.Println("getTitle error:", err, stdout.String()) fmt.Println("getYtdlpTitle error:", err, stdout.String())
return Meta{}, err return "", err
} else { }
isDot := func(r rune) bool { return strings.TrimSpace(stdout.String()), nil
return r == '.'
} }
fields := strings.FieldsFunc(strings.TrimSpace(stdout.String()), isDot) func getYtdlpArtist(url string, args []string) (string, error) {
if len(fields) < 2 { ytdlp := "yt-dlp"
return Meta{}, errors.New("couldn't parse ytdlp output") args = append(args, "--simulate", "--print", "%(uploader)s", url)
fmt.Println(ytdlp, strings.Join(args, " "))
cmd := exec.Command(ytdlp, args...)
var stdout bytes.Buffer
cmd.Stdout = &stdout
err := cmd.Run()
if err != nil {
fmt.Println("getYtdlpArtist error:", err, stdout.String())
return "", err
} }
return Meta{ return strings.TrimSpace(stdout.String()), nil
title: strings.Join(fields[:len(fields)-1], "."),
ext: fields[len(fields)-1],
}, nil
} }
func getYtdlpExt(url string, args []string) (string, error) {
ytdlp := "yt-dlp"
args = append(args, "--simulate", "--print", "%(ext)s", url)
fmt.Println(ytdlp, strings.Join(args, " "))
cmd := exec.Command(ytdlp, args...)
var stdout bytes.Buffer
cmd.Stdout = &stdout
err := cmd.Run()
if err != nil {
fmt.Println("getYtdlpExt error:", err, stdout.String())
return "", err
}
return strings.TrimSpace(stdout.String()), nil
}
func getYtdlpMeta(url string, args []string) (Meta, error) {
meta := Meta{}
var err error
meta.title, err = getYtdlpTitle(url, args)
if err != nil {
}
meta.artist, err = getYtdlpArtist(url, args)
if err != nil {
}
meta.ext, err = getYtdlpExt(url, args)
if err != nil {
}
return meta, nil
}
func getYtdlpAudioMeta(url string) (Meta, error) {
args := []string{"-f", "bestaudio"}
return getYtdlpMeta(url, args)
}
func getYtdlpVideoMeta(url string) (Meta, error) {
args := []string{"-f", "bestvideo+bestaudio/best"}
return getYtdlpMeta(url, args)
} }
// return the length in seconds of a video file at `path` // return the length in seconds of a video file at `path`
@@ -270,6 +337,10 @@ type VideoMeta struct {
fps float64 fps float64
} }
type AudioMeta struct {
rate uint
}
func getVideoMeta(path string) (VideoMeta, error) { func getVideoMeta(path string) (VideoMeta, error) {
w, err := getVideoWidth(path) w, err := getVideoWidth(path)
if err != nil { if err != nil {
@@ -290,27 +361,69 @@ func getVideoMeta(path string) (VideoMeta, error) {
}, nil }, nil
} }
func processOriginal(originalID uint, videoFilename string, origMeta Meta) { func getAudioBitrate(path string) (uint, error) {
videoFilepath := filepath.Join(getDataDir(), videoFilename) ffprobe := "ffprobe"
ffprobeArgs := []string{
"-v", "quiet",
"-select_streams", "a:0",
"-show_entries", "stream=bit_rate",
"-of", "default=noprint_wrappers=1:nokey=1",
path}
fmt.Println(ffprobe, strings.Join(ffprobeArgs, " "))
cmd := exec.Command(ffprobe, ffprobeArgs...)
var stdout bytes.Buffer
cmd.Stdout = &stdout
err := cmd.Run()
if err != nil {
fmt.Println("getAudioBitrate error:", err, stdout.String())
return 0, err
}
bitrateStr := strings.TrimSpace(stdout.String())
bitrate, err := strconv.ParseUint(bitrateStr, 10, 32)
if err != nil {
fmt.Println("getAudioBitrate error:", err)
return 0, err
}
return uint(bitrate), nil
}
func getAudioMeta(path string) (AudioMeta, error) {
rate, err := getAudioBitrate(path)
if err != nil {
return AudioMeta{}, err
}
return AudioMeta{
rate: rate,
}, nil
}
func processOriginal(originalID uint) {
// check if there is an original video
hasOriginalVideo := true
hasOriginalAudio := true
var video Video
var audio Audio
err := db.Where("source = ?", "original").Where("original_id = ?", originalID).First(&video).Error
if err == gorm.ErrRecordNotFound {
hasOriginalVideo = false
}
err = db.Where("source = ?", "original").Where("original_id = ?", originalID).First(&audio).Error
if err == gorm.ErrRecordNotFound {
hasOriginalAudio = false
}
if hasOriginalVideo {
videoFilepath := filepath.Join(getDataDir(), video.Filename)
_, err := os.Stat(videoFilepath) _, err := os.Stat(videoFilepath)
if os.IsNotExist(err) { if os.IsNotExist(err) {
fmt.Println("Skipping non-existant file for processOriginal") fmt.Println("Skipping non-existant file for processOriginal")
return return
} }
// create video entry for original
video := Video{
OriginalID: originalID,
Filename: videoFilename,
Source: "original",
Type: origMeta.ext,
}
fmt.Println("create Video", video)
if err := db.Create(&video).Error; err != nil {
fmt.Println(err)
}
videoMeta, err := getVideoMeta(videoFilepath) videoMeta, err := getVideoMeta(videoFilepath)
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
@@ -355,43 +468,124 @@ func processOriginal(originalID uint, videoFilename string, origMeta Meta) {
db.Create(&t) db.Create(&t)
} }
} }
} else if hasOriginalAudio {
audioFilepath := filepath.Join(getDataDir(), audio.Filename)
_, err := os.Stat(audioFilepath)
if os.IsNotExist(err) {
fmt.Println("Skipping non-existant audio file for processOriginal")
return
}
audioMeta, err := getAudioMeta(audioFilepath)
if err != nil {
fmt.Println(err)
} else {
fmt.Println(audioMeta)
db.Model(&Audio{}).Where("id = ?", audio.ID).Update("rate", fmt.Sprintf("%dk", audioMeta.rate/1000))
} }
func startDownload(originalID uint, videoURL string) { size, err := getSize(audioFilepath)
if err == nil {
db.Model(&Audio{}).Where("id = ?", audio.ID).Update("size", humanSize(size))
}
// create audio transcodes
for _, bitrate := range []uint{64, 96, 128, 160, 192} {
t := Transcode{
SrcID: audio.ID,
OriginalID: originalID,
SrcKind: "audio",
DstKind: "audio",
Rate: bitrate,
TimeSubmit: time.Now(),
Status: "pending",
}
db.Create(&t)
}
} else {
fmt.Println("No original video or audio found in processOriginal")
}
}
func startDownload(originalID uint, videoURL string, audioOnly bool) {
fmt.Println("startDownload audioOnly=", audioOnly)
// metadata phase // metadata phase
db.Model(&Original{}).Where("id = ?", originalID).Update("status", "metadata") db.Model(&Original{}).Where("id = ?", originalID).Update("status", "metadata")
origMeta, err := getMeta(videoURL) var origMeta Meta
var err error
if audioOnly {
origMeta, err = getYtdlpAudioMeta(videoURL)
} else {
origMeta, err = getYtdlpVideoMeta(videoURL)
}
if err != nil { if err != nil {
db.Model(&Original{}).Where("id = ?", originalID).Update("status", "failed") db.Model(&Original{}).Where("id = ?", originalID).Update("status", "failed")
return return
} }
fmt.Printf("original metadata %v\n", origMeta) fmt.Printf("original metadata %v\n", origMeta)
db.Model(&Original{}).Where("id = ?", originalID).Update("title", origMeta.title) db.Model(&Original{}).Where("id = ?", originalID).Update("title", origMeta.title)
db.Model(&Original{}).Where("id = ?", originalID).Update("artist", origMeta.artist)
// download original // download original
db.Model(&Original{}).Where("id = ?", originalID).Update("status", "downloading") db.Model(&Original{}).Where("id = ?", originalID).Update("status", "downloading")
videoFilename := fmt.Sprintf("%d-%s.%s", originalID, origMeta.title, origMeta.ext) dlFilename := fmt.Sprintf("%d-%s.%s", originalID, origMeta.title, origMeta.ext)
videoFilepath := filepath.Join(getDataDir(), videoFilename) dlFilepath := filepath.Join(getDataDir(), dlFilename)
cmd := exec.Command("yt-dlp",
"-f", "bestvideo+bestaudio/best", var args []string
"-o", videoFilepath, if audioOnly {
videoURL) args = []string{"-f", "bestaudio"}
} else {
args = []string{"-f", "bestvideo+bestaudio/best"}
}
ytdlp := "yt-dlp"
ytdlpArgs := append(args, "-o", dlFilepath, videoURL)
fmt.Println(ytdlp, strings.Join(ytdlpArgs, " "))
cmd := exec.Command(ytdlp, ytdlpArgs...)
err = cmd.Run() err = cmd.Run()
if err != nil { if err != nil {
db.Model(&Original{}).Where("id = ?", originalID).Update("status", "failed") db.Model(&Original{}).Where("id = ?", originalID).Update("status", "failed")
return return
} }
db.Model(&Original{}).Where("id = ?", originalID).Update("status", "completed")
processOriginal(originalID, videoFilename, origMeta) if audioOnly {
audio := Audio{
OriginalID: originalID,
Filename: dlFilename,
Source: "original",
Type: origMeta.ext,
}
fmt.Println("create Audio", audio)
db.Create(&audio)
} else {
video := Video{
OriginalID: originalID,
Filename: dlFilename,
Source: "original",
Type: origMeta.ext,
}
fmt.Println("create Video", video)
db.Create(&video)
}
db.Model(&Original{}).Where("id = ?", originalID).Update("status", "completed")
processOriginal(originalID)
} }
func videosHandler(c echo.Context) error { func videosHandler(c echo.Context) error {
userID := c.Get("user_id").(uint) userID := c.Get("user_id").(uint)
var origs []Original var origs []Original
db.Where("user_id = ?", userID).Find(&origs) db.Where("user_id = ?", userID).Find(&origs)
return c.Render(http.StatusOK, "videos.html", map[string]interface{}{"videos": origs}) return c.Render(http.StatusOK, "videos.html",
map[string]interface{}{
"videos": origs,
"build_id": getGitSHA(),
})
} }
type VideoTemplate struct { type VideoTemplate struct {
@@ -419,7 +613,7 @@ func videoHandler(c echo.Context) error {
dataDir := getDataDir() dataDir := getDataDir()
// create remporary URLs // create temporary URLs
var videoURLs []VideoTemplate var videoURLs []VideoTemplate
var audioURLs []AudioTemplate var audioURLs []AudioTemplate
for _, video := range videos { for _, video := range videos {
@@ -443,6 +637,7 @@ func videoHandler(c echo.Context) error {
"videos": videoURLs, "videos": videoURLs,
"audios": audioURLs, "audios": audioURLs,
"dataDir": dataDir, "dataDir": dataDir,
"build_id": getGitSHA(),
}) })
} }
@@ -467,7 +662,8 @@ func videoRestartHandler(c echo.Context) error {
orig.Status = "pending" orig.Status = "pending"
db.Save(&orig) db.Save(&orig)
go startDownload(uint(id), orig.URL)
go startDownload(uint(id), orig.URL, orig.Audio)
return c.Redirect(http.StatusSeeOther, "/videos") return c.Redirect(http.StatusSeeOther, "/videos")
} }

View File

@@ -16,8 +16,10 @@ type Original struct {
UserID uint UserID uint
URL string URL string
Title string Title string
Author string Artist string
Status string // "pending", "metadata", "downloading", "completed", "failed", "cancelled" Status string // "pending", "metadata", "downloading", "completed", "failed", "cancelled"
Audio bool // video download requested
Video bool // audio download requested
} }
type Video struct { type Video struct {
@@ -37,7 +39,7 @@ type Video struct {
type Transcode struct { type Transcode struct {
gorm.Model gorm.Model
Status string // "pending", "running", "failed" Status string // "pending", "running", "failed"
SrcID uint // Video.ID of the source file SrcID uint // Video.ID or Audio.ID of the source file
OriginalID uint // Original.ID OriginalID uint // Original.ID
SrcKind string // "video", "audio" SrcKind string // "video", "audio"
DstKind string // "video", "audio" DstKind string // "video", "audio"
@@ -56,7 +58,8 @@ type Transcode struct {
type Audio struct { type Audio struct {
gorm.Model gorm.Model
OriginalID uint // Original.ID OriginalID uint // Original.ID
Rate string Source string // "original", "transcode"
Rate string // in kbps
Length string Length string
Size string Size string
Type string Type string

View File

@@ -11,6 +11,11 @@
<h1>Download Video</h1> <h1>Download Video</h1>
<form method="POST"> <form method="POST">
<input type="url" name="url" placeholder="Video URL" required> <input type="url" name="url" placeholder="Video URL" required>
<input type="radio" id="audio-video" name="color" value="audio-video" checked>
<label for="audio-video">Audio/Video</label>
<input type="radio" id="audio-video" name="color" value="audio">
<label for="audio">Audio Only</label>
<button type="submit">Download</button> <button type="submit">Download</button>
</form> </form>
<a href="/videos">View Downloaded Videos</a> <a href="/videos">View Downloaded Videos</a>

View File

@@ -71,6 +71,10 @@
</div> </div>
{{end}} {{end}}
<div class="footer">
<div class="build-id">Build ID: {{.build_id}}</div>
</div>
</body> </body>
</html> </html>

View File

@@ -31,6 +31,7 @@
<tr> <tr>
<th>Title</th> <th>Title</th>
<th>URL</th> <th>URL</th>
<th>Type</th>
<th>Status</th> <th>Status</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
@@ -44,6 +45,14 @@
{{end}} {{end}}
</td> </td>
<td><a href="{{.URL}}">{{.URL}}</a></td> <td><a href="{{.URL}}">{{.URL}}</a></td>
<td>
{{if .Audio}}
Audio
{{end}}
{{if .Video}}
Video
{{end}}
</td>
<td>{{.Status}}</td> <td>{{.Status}}</td>
<td> <td>
{{if eq .Status "completed"}} {{if eq .Status "completed"}}
@@ -68,6 +77,11 @@
</table> </table>
<p><a href="/download">Download New Video</a></p> <p><a href="/download">Download New Video</a></p>
<p><a href="/logout">Logout</a></p> <p><a href="/logout">Logout</a></p>
<div class="footer">
<div class="build-id">Build ID: {{.build_id}}</div>
</div>
</body> </body>
</html> </html>

View File

@@ -141,6 +141,56 @@ func videoToAudio(transID uint, bitrate uint, videoFilepath string) {
db.Delete(&trans) db.Delete(&trans)
} }
func audioToAudio(transID uint, bitrate uint, srcFilepath string) {
// determine destination path
dstFilename := uuid.Must(uuid.NewV7()).String()
dstFilename = fmt.Sprintf("%s.mp3", dstFilename)
dstFilepath := filepath.Join(getDataDir(), dstFilename)
// ensure destination directory
err := ensureDirFor(dstFilepath)
if err != nil {
fmt.Println("Error: couldn't create dir for ", dstFilepath, err)
db.Model(&Transcode{}).Where("id = ?", transID).Update("status", "failed")
return
}
ffmpeg := "ffmpeg"
ffmpegArgs := []string{"-i", srcFilepath, "-vn", "-acodec",
"mp3", "-b:a",
fmt.Sprintf("%dk", bitrate),
dstFilepath}
fmt.Println(ffmpeg, strings.Join(ffmpegArgs, " "))
cmd := exec.Command(ffmpeg, ffmpegArgs...)
db.Model(&Transcode{}).Where("id = ?", transID).Update("status", "running")
err = cmd.Run()
if err != nil {
fmt.Println("Error: convert to audio file", srcFilepath, "->", dstFilepath)
db.Model(&Transcode{}).Where("id = ?", transID).Update("status", "failed")
return
}
// look up original
var trans Transcode
db.First(&trans, "id = ?", transID)
var orig Original
db.First(&orig, "id = ?", trans.OriginalID)
// create audio record
audio := Audio{OriginalID: orig.ID, Filename: dstFilename, Rate: fmt.Sprintf("%dk", bitrate)}
fileSize, err := getSize(dstFilepath)
if err == nil {
audio.Size = humanSize(fileSize)
}
db.Create(&audio)
// complete transcode
db.Delete(&trans)
}
func transcodePending() { func transcodePending() {
fmt.Println("transcodePending...") fmt.Println("transcodePending...")
@@ -175,6 +225,17 @@ func transcodePending() {
fmt.Println("unexpected src/dst kinds for Transcode", trans) fmt.Println("unexpected src/dst kinds for Transcode", trans)
db.Delete(&trans) db.Delete(&trans)
} }
} else if trans.SrcKind == "audio" {
var srcAudio Audio
err = db.First(&srcAudio, "id = ?", trans.SrcID).Error
if err != nil {
fmt.Println("no such source audio for audio Transcode", trans)
db.Delete(&trans)
continue
}
srcFilepath := filepath.Join(getDataDir(), srcAudio.Filename)
audioToAudio(trans.ID, trans.Rate, srcFilepath)
} else { } else {
fmt.Println("unexpected src kind for Transcode", trans) fmt.Println("unexpected src kind for Transcode", trans)
db.Delete(&trans) db.Delete(&trans)