Files
ytdlp-site/handlers.go
2024-09-13 05:35:57 -06:00

794 lines
19 KiB
Go

package main
import (
"bytes"
"fmt"
"math"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/labstack/echo/v4"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
func registerHandler(c echo.Context) error {
return c.Render(http.StatusOK, "register.html", nil)
}
func registerPostHandler(c echo.Context) error {
username := c.FormValue("username")
password := c.FormValue("password")
err := CreateUser(db, username, password)
if err != nil {
return c.String(http.StatusInternalServerError, "Error creating user")
}
return c.Redirect(http.StatusSeeOther, "/login")
}
func loginHandler(c echo.Context) error {
return c.Render(http.StatusOK, "login.html", nil)
}
func homeHandler(c echo.Context) error {
return c.Render(http.StatusOK, "home.html", nil)
}
func loginPostHandler(c echo.Context) error {
username := c.FormValue("username")
password := c.FormValue("password")
var user User
if err := db.Where("username = ?", username).First(&user).Error; err != nil {
return c.String(http.StatusUnauthorized, "Invalid credentials")
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
return c.String(http.StatusUnauthorized, "Invalid credentials")
}
session, err := store.Get(c.Request(), "session")
if err != nil {
return c.String(http.StatusInternalServerError, "Unable to retrieve session")
}
session.Values["user_id"] = user.ID
err = session.Save(c.Request(), c.Response().Writer)
if err != nil {
return c.String(http.StatusInternalServerError, "Unable to save session")
}
session, _ = store.Get(c.Request(), "session")
_, ok := session.Values["user_id"]
if !ok {
return c.String(http.StatusInternalServerError, "user_id was not saved as expected")
}
fmt.Println("loginPostHandler: redirect to /download")
return c.Redirect(http.StatusSeeOther, "/download")
}
func logoutHandler(c echo.Context) error {
session, _ := store.Get(c.Request(), "session")
delete(session.Values, "user_id")
session.Save(c.Request(), c.Response().Writer)
return c.Redirect(http.StatusSeeOther, "/login")
}
func downloadHandler(c echo.Context) error {
return c.Render(http.StatusOK, "download.html", nil)
}
func downloadPostHandler(c echo.Context) error {
url := c.FormValue("url")
userID := c.Get("user_id").(uint)
vaStr := c.FormValue("color")
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)
go startDownload(original.ID, url, audioOnly)
return c.Redirect(http.StatusSeeOther, "/videos")
}
type Meta struct {
title string
artist string
ext string
}
func getYtdlpTitle(url string, args []string) (string, error) {
ytdlp := "yt-dlp"
args = append(args, "--simulate", "--print", "%(title)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("getYtdlpTitle error:", err, stdout.String())
return "", err
}
return strings.TrimSpace(stdout.String()), nil
}
func getYtdlpArtist(url string, args []string) (string, error) {
ytdlp := "yt-dlp"
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 strings.TrimSpace(stdout.String()), 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
}
result := strings.TrimSpace(stdout.String())
fmt.Println(result)
return result, 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`
func getLength(path string) (float64, error) {
cmd := exec.Command("ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", path)
var stdout bytes.Buffer
cmd.Stdout = &stdout
err := cmd.Run()
if err != nil {
fmt.Println("getLength cmd error:", err)
return -1, err
}
result, err := strconv.ParseFloat(strings.TrimSpace(stdout.String()), 64)
if err != nil {
fmt.Println("getLength parse error:", err, stdout.String())
}
return result, nil
}
func getVideoWidth(path string) (uint, error) {
cmd := exec.Command("ffprobe", "-v", "error", "-select_streams",
"v:0", "-count_packets", "-show_entries",
"stream=width", "-of", "csv=p=0", path)
var stdout bytes.Buffer
cmd.Stdout = &stdout
err := cmd.Run()
if err != nil {
fmt.Println("getVideoWidth cmd error:", err)
return 0, err
}
result, err := strconv.ParseUint(strings.TrimSpace(stdout.String()), 10, 32)
if err != nil {
fmt.Println("getVideoWidth parse error:", err, stdout.String())
}
return uint(result), nil
}
func getVideoHeight(path string) (uint, error) {
cmd := exec.Command("ffprobe", "-v", "error", "-select_streams",
"v:0", "-count_packets", "-show_entries",
"stream=height", "-of", "csv=p=0", path)
var stdout bytes.Buffer
cmd.Stdout = &stdout
err := cmd.Run()
if err != nil {
fmt.Println("getVideoHeight cmd error:", err)
return 0, err
}
result, err := strconv.ParseUint(strings.TrimSpace(stdout.String()), 10, 32)
if err != nil {
fmt.Println("getVideoHeight parse error:", err, stdout.String())
}
return uint(result), nil
}
func getVideoFPS(path string) (float64, error) {
ffprobe := "ffprobe"
args := []string{"-v", "error", "-select_streams",
"v:0", "-count_packets", "-show_entries",
"stream=r_frame_rate", "-of", "csv=p=0", path}
fmt.Println(ffprobe, strings.Join(args, " "))
cmd := exec.Command(ffprobe, args...)
var stdout bytes.Buffer
cmd.Stdout = &stdout
err := cmd.Run()
if err != nil {
fmt.Println("getVideoFPS cmd error:", err)
return -1, err
}
// TODO: this produces a string like "num/denom", do the division
parts := strings.Split(strings.TrimSpace(stdout.String()), "/")
if len(parts) != 2 {
fmt.Println("getVideoFPS split error:", err, stdout.String())
}
num, err := strconv.ParseFloat(parts[0], 64)
if err != nil {
fmt.Println("getVideoFPS numerator parse error:", err, stdout.String())
}
denom, err := strconv.ParseFloat(parts[1], 64)
if err != nil {
fmt.Println("getVideoFPS denominator parse error:", err, stdout.String())
}
if denom == 0 {
fmt.Println("getVideoFPS denominator is zero error:", stdout.String())
}
return num / denom, nil
}
func humanLength(s float64) string {
ss := int64(s)
mm, ss := ss/60, ss%60
hh, mm := mm/60, mm%60
return fmt.Sprintf("%d:%02d:%02d", hh, mm, ss)
}
func getSize(path string) (int64, error) {
fi, err := os.Stat(path)
if err != nil {
return -1, err
}
return fi.Size(), nil
}
func humanSize(bytes int64) string {
const (
KiB = 1024
MiB = 1024 * KiB
GiB = 1024 * MiB
)
if bytes >= GiB {
return fmt.Sprintf("%.1f GiB", float64(bytes)/float64(GiB))
} else if bytes >= MiB {
return fmt.Sprintf("%.1f MiB", float64(bytes)/float64(MiB))
} else if bytes >= KiB {
return fmt.Sprintf("%.1f KiB", float64(bytes)/float64(KiB))
}
return fmt.Sprintf("%d bytes", bytes)
}
type VideoMeta struct {
width uint
height uint
fps float64
}
type AudioMeta struct {
rate uint
}
func getVideoMeta(path string) (VideoMeta, error) {
w, err := getVideoWidth(path)
if err != nil {
return VideoMeta{}, err
}
h, err := getVideoHeight(path)
if err != nil {
return VideoMeta{}, err
}
fps, err := getVideoFPS(path)
if err != nil {
return VideoMeta{}, err
}
return VideoMeta{
width: w,
height: h,
fps: fps,
}, nil
}
func getAudioDuration(path string) (float64, error) {
ffprobe := "ffprobe"
ffprobeArgs := []string{
"-v", "error",
"-show_entries", "format=duration",
"-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("getAudioDuration error:", err, stdout.String())
return 0, err
}
durationStr := strings.TrimSpace(stdout.String())
return strconv.ParseFloat(durationStr, 64)
}
func getAudioBitrate(path string) (uint, error) {
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())
// for opus files, or other files with variable birates, this may come back "N/A"
if bitrateStr == "N/A" {
size, err := getSize(path)
if err != nil {
return 0, err
}
dur, err := getAudioDuration(path)
if err != nil {
return 0, err
}
return uint(math.Round(float64(size) / dur)), nil
} else {
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)
if os.IsNotExist(err) {
fmt.Println("Skipping non-existant file for processOriginal")
return
}
videoMeta, err := getVideoMeta(videoFilepath)
if err != nil {
fmt.Println(err)
} else {
fmt.Println(videoMeta)
db.Model(&Video{}).Where("id = ?", video.ID).Update("fps", videoMeta.fps)
db.Model(&Video{}).Where("id = ?", video.ID).Update("width", videoMeta.width)
db.Model(&Video{}).Where("id = ?", video.ID).Update("height", videoMeta.height)
}
videoSize, err := getSize(videoFilepath)
if err == nil {
db.Model(&Video{}).Where("id = ?", video.ID).Update("size", humanSize(videoSize))
}
// create audio transcodes
for _, bitrate := range []uint{64, 96, 128, 160, 192} {
t := Transcode{
SrcID: video.ID,
OriginalID: originalID,
SrcKind: "video",
DstKind: "audio",
Rate: bitrate,
TimeSubmit: time.Now(),
Status: "pending",
}
db.Create(&t)
}
// create video transcodes
for _, targetHeight := range []uint{144, 240, 360, 480, 720, 1080} {
if targetHeight <= videoMeta.height {
t := Transcode{
SrcID: video.ID,
OriginalID: originalID,
SrcKind: "video",
DstKind: "video",
Height: targetHeight,
TimeSubmit: time.Now(),
Status: "pending",
}
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("kbps", fmt.Sprintf("%.1fk", float64(audioMeta.rate)/1000))
}
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
db.Model(&Original{}).Where("id = ?", originalID).Update("status", "metadata")
var origMeta Meta
var err error
if audioOnly {
origMeta, err = getYtdlpAudioMeta(videoURL)
} else {
origMeta, err = getYtdlpVideoMeta(videoURL)
}
if err != nil {
db.Model(&Original{}).Where("id = ?", originalID).Update("status", "failed")
return
}
fmt.Printf("original metadata %v\n", origMeta)
db.Model(&Original{}).Where("id = ?", originalID).Update("title", origMeta.title)
db.Model(&Original{}).Where("id = ?", originalID).Update("artist", origMeta.artist)
// download original
db.Model(&Original{}).Where("id = ?", originalID).Update("status", "downloading")
dlFilename := fmt.Sprintf("%d-%s.%s", originalID, origMeta.title, origMeta.ext)
dlFilepath := filepath.Join(getDataDir(), dlFilename)
var args []string
if audioOnly {
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()
if err != nil {
db.Model(&Original{}).Where("id = ?", originalID).Update("status", "failed")
return
}
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 {
userID := c.Get("user_id").(uint)
var origs []Original
db.Where("user_id = ?", userID).Find(&origs)
return c.Render(http.StatusOK, "videos.html",
map[string]interface{}{
"videos": origs,
"build_id": getGitSHA(),
})
}
type VideoTemplate struct {
Video
TempURL
}
type AudioTemplate struct {
Audio
TempURL
}
func videoHandler(c echo.Context) error {
id, _ := strconv.Atoi(c.Param("id"))
var orig Original
if err := db.First(&orig, id).Error; err != nil {
return c.Redirect(http.StatusSeeOther, "/videos")
}
var videos []Video
db.Where("original_id = ?", id).Find(&videos)
var audios []Audio
db.Where("original_id = ?", id).Find(&audios)
dataDir := getDataDir()
// create temporary URLs
var videoURLs []VideoTemplate
var audioURLs []AudioTemplate
for _, video := range videos {
tempURL, err := CreateTempURL(filepath.Join(dataDir, video.Filename))
if err != nil {
continue
}
videoURLs = append(videoURLs, VideoTemplate{video, tempURL})
}
for _, audio := range audios {
tempURL, err := CreateTempURL(filepath.Join(dataDir, audio.Filename))
if err != nil {
continue
}
audioURLs = append(audioURLs, AudioTemplate{audio, tempURL})
}
return c.Render(http.StatusOK, "video.html",
map[string]interface{}{
"original": orig,
"videos": videoURLs,
"audios": audioURLs,
"dataDir": dataDir,
"build_id": getGitSHA(),
})
}
func videoCancelHandler(c echo.Context) error {
id, _ := strconv.Atoi(c.Param("id"))
var video Video
if err := db.First(&video, id).Error; err != nil {
return c.Redirect(http.StatusSeeOther, "/videos")
}
// Cancel the download (this is a simplified version, you might need to implement a more robust cancellation mechanism)
return c.Redirect(http.StatusSeeOther, "/videos")
}
func videoRestartHandler(c echo.Context) error {
id, _ := strconv.Atoi(c.Param("id"))
var orig Original
if err := db.First(&orig, id).Error; err != nil {
return c.Redirect(http.StatusSeeOther, "/videos")
}
orig.Status = "pending"
db.Save(&orig)
go startDownload(uint(id), orig.URL, orig.Audio)
return c.Redirect(http.StatusSeeOther, "/videos")
}
func deleteTranscodes(originalID int) {
fmt.Println("Delete Transcode entries for Original", originalID)
db.Delete(&Transcode{}, "original_id = ?", originalID)
}
func deleteTranscodedVideos(originalID int) {
var videos []Video
db.Where("original_id = ?", originalID).Where("source = ?", "transcoded").Find(&videos)
for _, video := range videos {
path := filepath.Join(getDataDir(), video.Filename)
fmt.Println("remove", path)
err := os.Remove(path)
if err != nil {
fmt.Println("error removing", path, err)
}
}
db.Delete(&Video{}, "original_id = ?", originalID)
}
func deleteOriginalVideos(originalID int) {
var videos []Video
db.Where("original_id = ?", originalID).Where("source = ?", "original").Find(&videos)
for _, video := range videos {
path := filepath.Join(getDataDir(), video.Filename)
fmt.Println("remove", path)
err := os.Remove(path)
if err != nil {
fmt.Println("error removing", path, err)
}
}
db.Delete(&Video{}, "original_id = ?", originalID)
}
func deleteAudios(originalID int) {
// delete audios
var audios []Audio
db.Where("original_id = ?", originalID).Find(&audios)
for _, audio := range audios {
path := filepath.Join(getDataDir(), audio.Filename)
fmt.Println("remove", path)
err := os.Remove(path)
if err != nil {
fmt.Println("error removing", path, err)
}
}
db.Delete(&Video{}, "original_id = ?", originalID)
}
func videoDeleteHandler(c echo.Context) error {
id, _ := strconv.Atoi(c.Param("id"))
var orig Original
if err := db.First(&orig, id).Error; err != nil {
return c.Redirect(http.StatusSeeOther, "/videos")
}
deleteTranscodes(id)
deleteTranscodedVideos(id)
deleteOriginalVideos(id)
deleteAudios(id)
db.Delete(&orig)
return c.Redirect(http.StatusSeeOther, "/videos")
}
func tempHandler(c echo.Context) error {
token := c.Param("token")
var tempURL TempURL
if err := db.Where("token = ? AND expires_at > ?", token, time.Now()).First(&tempURL).Error; err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "Invalid or expired token"})
}
return c.File(tempURL.FilePath)
}
// func processHandler(c echo.Context) error {
// id, _ := strconv.Atoi(c.Param("id"))
// deleteTranscodes(id)
// deleteAudios(id)
// deleteTranscodedVideos(id)
// processOriginal(id)
// }