on-demand transcoding with concurrency limit

This commit is contained in:
Carl Pearson
2024-10-18 15:21:33 -06:00
parent da720508b1
commit 459e899efe
9 changed files with 241 additions and 134 deletions

View File

@@ -14,6 +14,7 @@ ADD handlers /src/handlers
ADD media /src/media ADD media /src/media
ADD originals /src/originals ADD originals /src/originals
Add playlists /src/playlists Add playlists /src/playlists
ADD transcodes /src/transcodes
Add ytdlp /src/ytdlp Add ytdlp /src/ytdlp
ADD go.mod /src/. ADD go.mod /src/.

View File

@@ -22,6 +22,7 @@ import (
"ytdlp-site/media" "ytdlp-site/media"
"ytdlp-site/originals" "ytdlp-site/originals"
"ytdlp-site/playlists" "ytdlp-site/playlists"
"ytdlp-site/transcodes"
"ytdlp-site/ytdlp" "ytdlp-site/ytdlp"
) )
@@ -461,21 +462,46 @@ func getAudioMeta(path string) (AudioMeta, error) {
}, nil }, nil
} }
func addAudioTranscode(mediaId, originalId, bitrate uint, srcKind string) { func newAudioTranscode(mediaId, originalId, kbps uint, srcKind string) {
t := Transcode{ t := transcodes.Transcode{
SrcID: mediaId, SrcID: mediaId,
OriginalID: originalId, OriginalID: originalId,
SrcKind: srcKind, SrcKind: srcKind,
DstKind: "audio", DstKind: "audio",
Rate: bitrate, Kbps: kbps,
TimeSubmit: time.Now(), TimeSubmit: time.Now(),
Status: "pending", Status: "pending",
} }
db.Create(&t) db.Create(&t)
if srcKind == "video" {
var srcVideo media.Video
err := db.First(&srcVideo, "id = ?", t.SrcID).Error
if err != nil {
fmt.Println("no such source video for video Transcode", t)
db.Delete(&t)
return
}
srcFilepath := filepath.Join(config.GetDataDir(), srcVideo.Filename)
go videoToAudio(sem, t.ID, srcFilepath)
} else if srcKind == "audio" {
var srcAudio media.Audio
err := db.First(&srcAudio, "id = ?", t.SrcID).Error
if err != nil {
log.Errorln("no such source audio for audio Transcode", t)
db.Delete(&t)
return
}
srcFilepath := filepath.Join(config.GetDataDir(), srcAudio.Filename)
go audioToAudio(sem, t.ID, srcFilepath)
} else {
fmt.Println("unexpected src/dst kinds for Transcode", t)
db.Delete(&t)
}
} }
func addVideoTranscode(videoId, originalId, targetHeight uint, targetFPS float64) { func newVideoTranscode(videoId, originalId, targetHeight uint, targetFPS float64) {
t := Transcode{ t := transcodes.Transcode{
SrcID: videoId, SrcID: videoId,
OriginalID: originalId, OriginalID: originalId,
SrcKind: "video", SrcKind: "video",
@@ -486,6 +512,17 @@ func addVideoTranscode(videoId, originalId, targetHeight uint, targetFPS float64
Status: "pending", Status: "pending",
} }
db.Create(&t) db.Create(&t)
var srcVideo media.Video
err := db.First(&srcVideo, "id = ?", t.SrcID).Error
if err != nil {
fmt.Println("no such source video for video Transcode", t)
db.Delete(&t)
return
}
srcFilepath := filepath.Join(config.GetDataDir(), srcVideo.Filename)
go videoToVideo(sem, t.ID, srcFilepath)
} }
func processOriginal(originalID uint) { func processOriginal(originalID uint) {
@@ -514,14 +551,14 @@ func processOriginal(originalID uint) {
} }
// create audio transcodes // create audio transcodes
for _, bitrate := range []uint{64 /*, 96, 128, 160, 192*/} { for _, kbps := range []uint{64 /*, 96, 128, 160, 192*/} {
addAudioTranscode(video.ID, originalID, bitrate, "video") newAudioTranscode(video.ID, originalID, kbps, "video")
} }
// create video transcodes // create video transcodes
for _, targetHeight := range []uint{480, 240, 144} { for _, targetHeight := range []uint{480, 240, 144} {
if targetHeight <= video.Height { if targetHeight <= video.Height {
addVideoTranscode(video.ID, originalID, targetHeight, video.FPS) newVideoTranscode(video.ID, originalID, targetHeight, video.FPS)
break break
} }
} }
@@ -536,8 +573,8 @@ func processOriginal(originalID uint) {
} }
// create audio transcodes // create audio transcodes
for _, bitrate := range []uint{64 /*, 96, 128, 160, 192*/} { for _, kbps := range []uint{64 /*, 96, 128, 160, 192*/} {
addAudioTranscode(audio.ID, originalID, bitrate, "audio") newAudioTranscode(audio.ID, originalID, kbps, "audio")
} }
} else { } else {
@@ -550,7 +587,7 @@ func startDownload(originalID uint, videoURL string, audioOnly bool) {
log.Debugf("startDownload audioOnly=%t", audioOnly) log.Debugf("startDownload audioOnly=%t", audioOnly)
// metadata phase // metadata phase
originals.SetStatus(db, originalID, originals.StatusMetadata) originals.SetStatus(originalID, originals.StatusMetadata)
var origMeta Meta var origMeta Meta
var err error var err error
if audioOnly { if audioOnly {
@@ -560,7 +597,7 @@ func startDownload(originalID uint, videoURL string, audioOnly bool) {
} }
if err != nil { if err != nil {
log.Errorln("couldn't retrieve metadata:", err) log.Errorln("couldn't retrieve metadata:", err)
originals.SetStatus(db, originalID, originals.StatusFailed) originals.SetStatus(originalID, originals.StatusFailed)
return return
} }
log.Debugf("original metadata %v", origMeta) log.Debugf("original metadata %v", origMeta)
@@ -570,19 +607,19 @@ func startDownload(originalID uint, videoURL string, audioOnly bool) {
}).Error }).Error
if err != nil { if err != nil {
log.Errorln("couldn't store metadata:", err) log.Errorln("couldn't store metadata:", err)
originals.SetStatus(db, originalID, originals.StatusFailed) originals.SetStatus(originalID, originals.StatusFailed)
return return
} }
// download original // download original
originals.SetStatus(db, originalID, originals.StatusDownloading) originals.SetStatus(originalID, originals.StatusDownloading)
// create temporary directory // create temporary directory
// do this in the data directory since /tmp is sometimes a different filesystem // do this in the data directory since /tmp is sometimes a different filesystem
tempDir, err := os.MkdirTemp(config.GetDataDir(), "dl") tempDir, err := os.MkdirTemp(config.GetDataDir(), "dl")
if err != nil { if err != nil {
log.Errorln("Error creating temporary directory:", err) log.Errorln("Error creating temporary directory:", err)
originals.SetStatus(db, originalID, originals.StatusFailed) originals.SetStatus(originalID, originals.StatusFailed)
return return
} }
defer os.RemoveAll(tempDir) defer os.RemoveAll(tempDir)
@@ -603,7 +640,7 @@ func startDownload(originalID uint, videoURL string, audioOnly bool) {
err = cmd.Run() err = cmd.Run()
if err != nil { if err != nil {
log.Errorln("yt-dlp failed") log.Errorln("yt-dlp failed")
originals.SetStatus(db, originalID, originals.StatusFailed) originals.SetStatus(originalID, originals.StatusFailed)
return return
} }
@@ -611,7 +648,7 @@ func startDownload(originalID uint, videoURL string, audioOnly bool) {
dirEnts, err := os.ReadDir(tempDir) dirEnts, err := os.ReadDir(tempDir)
if err != nil { if err != nil {
log.Errorln("Error reading directory:", err) log.Errorln("Error reading directory:", err)
originals.SetStatus(db, originalID, originals.StatusFailed) originals.SetStatus(originalID, originals.StatusFailed)
return return
} }
dlFilename := "" dlFilename := ""
@@ -624,7 +661,7 @@ func startDownload(originalID uint, videoURL string, audioOnly bool) {
} }
if dlFilename == "" { if dlFilename == "" {
log.Errorln("couldn't find a downloaded file") log.Errorln("couldn't find a downloaded file")
originals.SetStatus(db, originalID, originals.StatusFailed) originals.SetStatus(originalID, originals.StatusFailed)
} }
// move to data directory // move to data directory
@@ -634,7 +671,7 @@ func startDownload(originalID uint, videoURL string, audioOnly bool) {
err = os.Rename(srcPath, dlFilepath) err = os.Rename(srcPath, dlFilepath)
if err != nil { if err != nil {
log.Errorln("rename downloaded media error", srcPath, "->", dlFilepath, ":", err) log.Errorln("rename downloaded media error", srcPath, "->", dlFilepath, ":", err)
originals.SetStatus(db, originalID, originals.StatusFailed) originals.SetStatus(originalID, originals.StatusFailed)
return return
} }
@@ -642,7 +679,7 @@ func startDownload(originalID uint, videoURL string, audioOnly bool) {
mediaMeta, err := getAudioMeta(dlFilepath) mediaMeta, err := getAudioMeta(dlFilepath)
if err != nil { if err != nil {
log.Errorln("couldn't get audio file metadata", err) log.Errorln("couldn't get audio file metadata", err)
originals.SetStatus(db, originalID, originals.StatusFailed) originals.SetStatus(originalID, originals.StatusFailed)
return return
} }
@@ -658,14 +695,14 @@ func startDownload(originalID uint, videoURL string, audioOnly bool) {
fmt.Println("create Audio", audio) fmt.Println("create Audio", audio)
if db.Create(&audio).Error != nil { if db.Create(&audio).Error != nil {
fmt.Println("Couldn't create audio entry", err) fmt.Println("Couldn't create audio entry", err)
originals.SetStatus(db, originalID, originals.StatusFailed) originals.SetStatus(originalID, originals.StatusFailed)
return return
} }
} else { } else {
mediaMeta, err := getVideoMeta(dlFilepath) mediaMeta, err := getVideoMeta(dlFilepath)
if err != nil { if err != nil {
log.Errorln("couldn't get video file metadata", err) log.Errorln("couldn't get video file metadata", err)
originals.SetStatus(db, originalID, originals.StatusFailed) originals.SetStatus(originalID, originals.StatusFailed)
return return
} }
@@ -684,12 +721,12 @@ func startDownload(originalID uint, videoURL string, audioOnly bool) {
log.Debugln("create Video", video) log.Debugln("create Video", video)
if db.Create(&video).Error != nil { if db.Create(&video).Error != nil {
log.Errorln("Couldn't create video entry", err) log.Errorln("Couldn't create video entry", err)
originals.SetStatus(db, originalID, originals.StatusFailed) originals.SetStatus(originalID, originals.StatusFailed)
return return
} }
} }
originals.SetStatus(db, originalID, originals.StatusDownloadCompleted) originals.SetStatus(originalID, originals.StatusDownloadCompleted)
processOriginal(originalID) processOriginal(originalID)
} }
@@ -697,14 +734,14 @@ func startPlaylist(id uint, url string, audioOnly bool) {
// retrieve playlist metadata // retrieve playlist metadata
pl, err := getYtdlpPlaylist(url) pl, err := getYtdlpPlaylist(url)
if err != nil { if err != nil {
playlists.SetStatus(db, id, playlists.StatusFailed) playlists.SetStatus(id, playlists.StatusFailed)
return return
} }
err = db.Model(&playlists.Playlist{}).Where("id = ?", id).Updates(map[string]interface{}{ err = db.Model(&playlists.Playlist{}).Where("id = ?", id).Updates(map[string]interface{}{
"title": pl.Title, "title": pl.Title,
}).Error }).Error
if err != nil { if err != nil {
playlists.SetStatus(db, id, playlists.StatusFailed) playlists.SetStatus(id, playlists.StatusFailed)
return return
} }
@@ -720,11 +757,11 @@ func startPlaylist(id uint, url string, audioOnly bool) {
} }
err = db.Create(&original).Error err = db.Create(&original).Error
if err != nil { if err != nil {
playlists.SetStatus(db, id, playlists.StatusFailed) playlists.SetStatus(id, playlists.StatusFailed)
return return
} }
} }
playlists.SetStatus(db, id, playlists.StatusCompleted) playlists.SetStatus(id, playlists.StatusCompleted)
} }
func videosHandler(c echo.Context) error { func videosHandler(c echo.Context) error {
@@ -898,7 +935,7 @@ func videoRestartHandler(c echo.Context) error {
func deleteTranscodes(originalID uint) { func deleteTranscodes(originalID uint) {
log.Debugln("Delete Transcode entries for Original", originalID) log.Debugln("Delete Transcode entries for Original", originalID)
db.Delete(&Transcode{}, "original_id = ?", originalID) db.Delete(&transcodes.Transcode{}, "original_id = ?", originalID)
} }
func deleteTranscodedVideos(originalID uint) { func deleteTranscodedVideos(originalID uint) {
@@ -1045,7 +1082,7 @@ func transcodeToVideoHandler(c echo.Context) error {
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
log.Errorf("no video record for original %d: %v", originalId, err) log.Errorf("no video record for original %d: %v", originalId, err)
} else { } else {
addVideoTranscode(video.ID, uint(originalId), uint(height), fps) newVideoTranscode(video.ID, uint(originalId), uint(height), fps)
} }
return c.Redirect(http.StatusSeeOther, referrer) return c.Redirect(http.StatusSeeOther, referrer)
@@ -1074,9 +1111,9 @@ func transcodeToAudioHandler(c echo.Context) error {
} }
if hasOriginalVideo { if hasOriginalVideo {
addAudioTranscode(video.ID, uint(originalId), uint(kbps), "video") newAudioTranscode(video.ID, uint(originalId), uint(kbps), "video")
} else if hasOriginalAudio { } else if hasOriginalAudio {
addAudioTranscode(audio.ID, uint(originalId), uint(kbps), "audio") newAudioTranscode(audio.ID, uint(originalId), uint(kbps), "audio")
} else { } else {
log.Errorln("no audio or video record for original", originalId) log.Errorln("no audio or video record for original", originalId)
} }
@@ -1102,7 +1139,7 @@ func processHandler(c echo.Context) error {
deleteAudiosWithSource(uint(id), "transcode") deleteAudiosWithSource(uint(id), "transcode")
deleteTranscodedVideos(uint(id)) deleteTranscodedVideos(uint(id))
err := originals.SetStatus(db, uint(id), originals.StatusDownloadCompleted) err := originals.SetStatus(uint(id), originals.StatusDownloadCompleted)
if err != nil { if err != nil {
log.Errorf("error while setting original %d status: %v", id, err) log.Errorf("error while setting original %d status: %v", id, err)
} }

10
main.go
View File

@@ -23,6 +23,7 @@ import (
"ytdlp-site/media" "ytdlp-site/media"
"ytdlp-site/originals" "ytdlp-site/originals"
"ytdlp-site/playlists" "ytdlp-site/playlists"
"ytdlp-site/transcodes"
"ytdlp-site/ytdlp" "ytdlp-site/ytdlp"
) )
@@ -57,6 +58,8 @@ func main() {
ffmpeg.Init(log) ffmpeg.Init(log)
handlers.Init(log) handlers.Init(log)
ytdlp.Init(log) ytdlp.Init(log)
originals.Init(log)
defer originals.Fini()
gormLogger := logger.New( gormLogger := logger.New(
golog.New(os.Stdout, "\r\n", golog.LstdFlags), // io writer golog.New(os.Stdout, "\r\n", golog.LstdFlags), // io writer
@@ -93,7 +96,7 @@ func main() {
// Migrate the schema // Migrate the schema
db.AutoMigrate(&originals.Original{}, &playlists.Playlist{}, &media.Video{}, db.AutoMigrate(&originals.Original{}, &playlists.Playlist{}, &media.Video{},
&media.Audio{}, &User{}, &TempURL{}, &Transcode{}) &media.Audio{}, &User{}, &TempURL{}, &transcodes.Transcode{})
database.Init(db, log) database.Init(db, log)
defer database.Fini() defer database.Fini()
@@ -168,8 +171,9 @@ func main() {
Secure: secure, Secure: secure,
} }
// start the transcode worker // tidy up the transcodes database
go transcodeWorker() log.Debug("tidy transcodes database...")
cleanupTranscodes()
// Start server // Start server
e.Logger.Fatal(e.Start(":8080")) e.Logger.Fatal(e.Start(":8080"))

View File

@@ -12,25 +12,6 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
type Transcode struct {
gorm.Model
Status string // "pending", "running", "failed"
SrcID uint // Video.ID or Audio.ID of the source file
OriginalID uint // Original.ID
SrcKind string // "video", "audio"
DstKind string // "video", "audio"
TimeSubmit time.Time
TimeStart time.Time
// video fields
Height uint // target height
Width uint // target width
FPS float64 // target FPS
// audio & video fields
Rate uint
}
type User struct { type User struct {
gorm.Model gorm.Model
Username string `gorm:"unique"` Username string `gorm:"unique"`

14
originals/init.go Normal file
View File

@@ -0,0 +1,14 @@
package originals
import "github.com/sirupsen/logrus"
var log *logrus.Logger
func Init(logger *logrus.Logger) error {
log = logger.WithFields(logrus.Fields{
"component": "originals",
}).Logger
return nil
}
func Fini() {}

View File

@@ -1,6 +1,11 @@
package originals package originals
import "gorm.io/gorm" import (
"ytdlp-site/database"
"ytdlp-site/transcodes"
"gorm.io/gorm"
)
type Status string type Status string
@@ -29,6 +34,29 @@ type Original struct {
PlaylistID uint // Playlist.ID (if part of a playlist) PlaylistID uint // Playlist.ID (if part of a playlist)
} }
func SetStatus(db *gorm.DB, id uint, status Status) error { func SetStatus(id uint, status Status) error {
db := database.Get()
log.Debugln("original", id, "status -> ", status)
return db.Model(&Original{}).Where("id = ?", id).Update("status", status).Error return db.Model(&Original{}).Where("id = ?", id).Update("status", status).Error
} }
// if there is an active transcode for this original,
// set the status to transcode. otherwise ,to completed
func SetStatusTranscodingOrCompleted(id uint) error {
db := database.Get()
var count int64
err := db.Model(&transcodes.Transcode{}).Where("original_id = ?", id).Count(&count).Error
if err != nil {
return err
}
if count > 0 {
log.Debugln("found transcodes for original", id)
return SetStatus(id, StatusTranscoding)
} else {
log.Debugln("no transcodes for original", id)
return SetStatus(id, StatusCompleted)
}
}

View File

@@ -1,6 +1,10 @@
package playlists package playlists
import "gorm.io/gorm" import (
"ytdlp-site/database"
"gorm.io/gorm"
)
type Status string type Status string
@@ -21,6 +25,7 @@ const (
StatusFailed Status = "failed" StatusFailed Status = "failed"
) )
func SetStatus(db *gorm.DB, id uint, status Status) error { func SetStatus(id uint, status Status) error {
db := database.Get()
return db.Model(&Playlist{}).Where("id = ?", id).Update("status", status).Error return db.Model(&Playlist{}).Where("id = ?", id).Update("status", status).Error
} }

26
transcodes/transcode.go Normal file
View File

@@ -0,0 +1,26 @@
package transcodes
import (
"time"
"gorm.io/gorm"
)
type Transcode struct {
gorm.Model
Status string // "pending", "running", "failed"
SrcID uint // Video.ID or Audio.ID of the source file
OriginalID uint // Original.ID
SrcKind string // "video", "audio"
DstKind string // "video", "audio"
TimeSubmit time.Time
TimeStart time.Time
// video fields
Height uint // target height
Width uint // target width
FPS float64 // target FPS
// audio & video fields
Kbps uint
}

View File

@@ -4,23 +4,35 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"time"
"ytdlp-site/config" "ytdlp-site/config"
"ytdlp-site/ffmpeg" "ytdlp-site/ffmpeg"
"ytdlp-site/media" "ytdlp-site/media"
"ytdlp-site/originals" "ytdlp-site/originals"
"ytdlp-site/transcodes"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
) )
const (
maxConcurrent = 2
)
var sem = make(chan struct{}, maxConcurrent)
func ensureDirFor(path string) error { func ensureDirFor(path string) error {
dir := filepath.Dir(path) dir := filepath.Dir(path)
log.Debugln("Create", dir) log.Debugln("Create", dir)
return os.MkdirAll(dir, 0700) return os.MkdirAll(dir, 0700)
} }
func videoToVideo(transID uint, height uint, fps float64, srcFilepath string) { func videoToVideo(sem chan struct{}, transID uint, srcFilepath string) {
sem <- struct{}{} // Acquire semaphore
defer func() { <-sem }() // release semaphore
var trans transcodes.Transcode
db.First(&trans, "id = ?", transID)
originals.SetStatus(trans.OriginalID, originals.StatusTranscoding)
// determine destination path // determine destination path
dstFilename := uuid.Must(uuid.NewV7()).String() dstFilename := uuid.Must(uuid.NewV7()).String()
@@ -30,29 +42,28 @@ func videoToVideo(transID uint, height uint, fps float64, srcFilepath string) {
err := ensureDirFor(dstFilepath) err := ensureDirFor(dstFilepath)
if err != nil { if err != nil {
fmt.Println("Error: couldn't create dir for ", dstFilepath, err) fmt.Println("Error: couldn't create dir for ", dstFilepath, err)
db.Model(&Transcode{}).Where("id = ?", transID).Update("status", "failed") db.Model(&transcodes.Transcode{}).Where("id = ?", trans.ID).Update("status", "failed")
return return
} }
// FIXME: ignoring any requested audio bitrate // FIXME: ignoring any requested audio bitrate
// determine audio bitrate // determine audio bitrate
var audioBitrate uint = 160 var audioBitrate uint = 160
if height <= 144 { if trans.Height <= 144 {
audioBitrate = 64 audioBitrate = 64
} else if height <= 480 { } else if trans.Height <= 480 {
audioBitrate = 96 audioBitrate = 96
} else if height < 720 { } else if trans.Height < 720 {
audioBitrate = 128 audioBitrate = 128
} }
// start ffmpeg // start ffmpeg
db.Model(&Transcode{}).Where("id = ?", transID).Update("status", "running") db.Model(&transcodes.Transcode{}).Where("id = ?", trans.ID).Update("status", "running")
var vf string var vf string
if fps > 0 { if trans.FPS > 0 {
vf = fmt.Sprintf("scale=-2:%d,fps=%f", height, fps) vf = fmt.Sprintf("scale=-2:%d,fps=%f", trans.Height, trans.FPS)
} else { } else {
vf = fmt.Sprintf("scale=-2:%d", height) vf = fmt.Sprintf("scale=-2:%d", trans.Height)
} }
stdout, stderr, err := ffmpeg.Ffmpeg("-i", srcFilepath, stdout, stderr, err := ffmpeg.Ffmpeg("-i", srcFilepath,
"-vf", vf, "-c:v", "libx264", "-vf", vf, "-c:v", "libx264",
@@ -60,13 +71,11 @@ func videoToVideo(transID uint, height uint, fps float64, srcFilepath string) {
dstFilepath) dstFilepath)
if err != nil { if err != nil {
fmt.Println("Error: convert to video file", srcFilepath, "->", dstFilepath, string(stdout), string(stderr)) fmt.Println("Error: convert to video file", srcFilepath, "->", dstFilepath, string(stdout), string(stderr))
db.Model(&Transcode{}).Where("id = ?", transID).Update("status", "failed") db.Model(&transcodes.Transcode{}).Where("id = ?", trans.ID).Update("status", "failed")
return return
} }
// look up original // look up original
var trans Transcode
db.First(&trans, transID)
var orig originals.Original var orig originals.Original
db.First(&orig, "id = ?", trans.OriginalID) db.First(&orig, "id = ?", trans.OriginalID)
@@ -94,9 +103,16 @@ func videoToVideo(transID uint, height uint, fps float64, srcFilepath string) {
// complete transcode // complete transcode
db.Delete(&trans) db.Delete(&trans)
originals.SetStatusTranscodingOrCompleted(trans.OriginalID)
} }
func videoToAudio(transID uint, kbps uint, videoFilepath string) { func videoToAudio(sem chan struct{}, transID uint, videoFilepath string) {
sem <- struct{}{} // Acquire semaphore
defer func() { <-sem }() // release semaphore
var trans transcodes.Transcode
db.First(&trans, "id = ?", transID)
originals.SetStatus(trans.OriginalID, originals.StatusTranscoding)
// determine destination path // determine destination path
audioFilename := uuid.Must(uuid.NewV7()).String() audioFilename := uuid.Must(uuid.NewV7()).String()
@@ -107,31 +123,30 @@ func videoToAudio(transID uint, kbps uint, videoFilepath string) {
err := ensureDirFor(audioFilepath) err := ensureDirFor(audioFilepath)
if err != nil { if err != nil {
fmt.Println("Error: couldn't create dir for ", audioFilepath, err) fmt.Println("Error: couldn't create dir for ", audioFilepath, err)
db.Model(&Transcode{}).Where("id = ?", transID).Update("status", "failed") db.Model(&transcodes.Transcode{}).Where("id = ?", transID).Update("status", "failed")
return return
} }
db.Model(&Transcode{}).Where("id = ?", transID).Update("status", "running") db.Model(&transcodes.Transcode{}).Where("id = ?", transID).Update("status", "running")
_, _, err = ffmpeg.Ffmpeg("-i", videoFilepath, "-vn", "-acodec", _, _, err = ffmpeg.Ffmpeg("-i", videoFilepath, "-vn", "-acodec",
"mp3", "-b:a", "mp3", "-b:a",
fmt.Sprintf("%dk", kbps), fmt.Sprintf("%dk", trans.Kbps),
audioFilepath) audioFilepath)
if err != nil { if err != nil {
fmt.Println("Error: convert to audio file", videoFilepath, "->", audioFilepath) fmt.Println("Error: convert to audio file", videoFilepath, "->", audioFilepath)
db.Model(&Transcode{}).Where("id = ?", transID).Update("status", "failed") db.Model(&transcodes.Transcode{}).Where("id = ?", transID).Update("status", "failed")
return return
} }
// look up original // look up original
var trans Transcode
db.First(&trans, "id = ?", transID)
var orig originals.Original var orig originals.Original
db.First(&orig, "id = ?", trans.OriginalID) db.First(&orig, "id = ?", trans.OriginalID)
// create audio record // create audio record
audio := media.Audio{OriginalID: orig.ID, audio := media.Audio{OriginalID: orig.ID,
Filename: audioFilename, Filename: audioFilename,
Bps: kbps * 1000, Bps: trans.Kbps * 1000,
Source: "transcode", Source: "transcode",
} }
@@ -148,9 +163,17 @@ func videoToAudio(transID uint, kbps uint, videoFilepath string) {
// complete transcode // complete transcode
db.Delete(&trans) db.Delete(&trans)
originals.SetStatusTranscodingOrCompleted(trans.OriginalID)
} }
func audioToAudio(transID uint, kbps uint, srcFilepath string) { func audioToAudio(sem chan struct{}, transID uint, srcFilepath string) {
sem <- struct{}{} // Acquire semaphore
defer func() { <-sem }() // release semaphore
var trans transcodes.Transcode
db.First(&trans, "id = ?", transID)
originals.SetStatus(trans.OriginalID, originals.StatusTranscoding)
// determine destination path // determine destination path
dstFilename := uuid.Must(uuid.NewV7()).String() dstFilename := uuid.Must(uuid.NewV7()).String()
@@ -161,24 +184,22 @@ func audioToAudio(transID uint, kbps uint, srcFilepath string) {
err := ensureDirFor(dstFilepath) err := ensureDirFor(dstFilepath)
if err != nil { if err != nil {
fmt.Println("Error: couldn't create dir for ", dstFilepath, err) fmt.Println("Error: couldn't create dir for ", dstFilepath, err)
db.Model(&Transcode{}).Where("id = ?", transID).Update("status", "failed") db.Model(&transcodes.Transcode{}).Where("id = ?", transID).Update("status", "failed")
return return
} }
db.Model(&Transcode{}).Where("id = ?", transID).Update("status", "running") db.Model(&transcodes.Transcode{}).Where("id = ?", transID).Update("status", "running")
_, _, err = ffmpeg.Ffmpeg("-i", srcFilepath, "-vn", "-acodec", _, _, err = ffmpeg.Ffmpeg("-i", srcFilepath, "-vn", "-acodec",
"mp3", "-b:a", "mp3", "-b:a",
fmt.Sprintf("%dk", kbps), fmt.Sprintf("%dk", trans.Kbps),
dstFilepath) dstFilepath)
if err != nil { if err != nil {
fmt.Println("Error: convert to audio file", srcFilepath, "->", dstFilepath) fmt.Println("Error: convert to audio file", srcFilepath, "->", dstFilepath)
db.Model(&Transcode{}).Where("id = ?", transID).Update("status", "failed") db.Model(&transcodes.Transcode{}).Where("id = ?", transID).Update("status", "failed")
return return
} }
// look up original // look up original
var trans Transcode
db.First(&trans, "id = ?", transID)
var orig originals.Original var orig originals.Original
db.First(&orig, "id = ?", trans.OriginalID) db.First(&orig, "id = ?", trans.OriginalID)
@@ -186,7 +207,7 @@ func audioToAudio(transID uint, kbps uint, srcFilepath string) {
audio := media.Audio{ audio := media.Audio{
OriginalID: orig.ID, OriginalID: orig.ID,
Filename: dstFilename, Filename: dstFilename,
Bps: kbps * 1000, Bps: trans.Kbps * 1000,
Source: "transcode", Source: "transcode",
} }
@@ -203,45 +224,44 @@ func audioToAudio(transID uint, kbps uint, srcFilepath string) {
// complete transcode // complete transcode
db.Delete(&trans) db.Delete(&trans)
originals.SetStatusTranscodingOrCompleted(trans.OriginalID)
} }
func transcodePending() { func cleanupTranscodes() {
log.Traceln("transcodePending...") log.Traceln("cleanupTranscode")
// any running jobs here got stuck or dead in the midde, so reset them // any running jobs here got stuck or dead in the midde, so reset them
db.Model(&Transcode{}).Where("status = ?", "running").Update("status", "pending") db.Model(&transcodes.Transcode{}).Where("status = ?", "running").Update("status", "pending")
// loop until no more pending jobs // find any originals with a transcode job -> transcoding
var originalsToUpdate []uint
db.Model(&originals.Original{}).
Select("id").
Where("id IN (?)",
db.Model(&transcodes.Transcode{}).
Select("original_id"),
).
Find(&originalsToUpdate)
db.Model(&originals.Original{}).
Where("id IN ?", originalsToUpdate).
Update("status", originals.StatusTranscoding)
// originals marked transcoding that don't have a transcode job -> complete
db.Model(&originals.Original{}).
Select("id").
Where("status = ? AND id NOT IN (?)",
originals.StatusTranscoding,
db.Model(&transcodes.Transcode{}).
Select("original_id"),
).
Find(&originalsToUpdate)
db.Model(&originals.Original{}).
Where("id IN ? AND status = ?", originalsToUpdate, originals.StatusTranscoding).
Update("status", originals.StatusCompleted)
// start any existing transcode jobs
for { for {
var trans transcodes.Transcode
var originalsToUpdate []uint
// find any originals with a transcode job and mark them as transcoding
db.Model(&originals.Original{}).
Select("id").
Where("id IN (?)",
db.Model(&Transcode{}).
Select("original_id"),
).
Find(&originalsToUpdate)
db.Model(&originals.Original{}).
Where("id IN ?", originalsToUpdate).
Update("status", originals.StatusTranscoding)
// originals marked transcoding that don't have a transcode job -> complete
db.Model(&originals.Original{}).
Select("id").
Where("status = ? AND id NOT IN (?)",
originals.StatusTranscoding,
db.Model(&Transcode{}).
Select("original_id"),
).
Find(&originalsToUpdate)
db.Model(&originals.Original{}).
Where("id IN ? AND status = ?", originalsToUpdate, originals.StatusTranscoding).
Update("status", originals.StatusCompleted)
var trans Transcode
err := db.Where("status = ?", "pending"). err := db.Where("status = ?", "pending").
Order("CASE " + Order("CASE " +
"WHEN dst_kind = 'video' AND height = 480 THEN 0 " + "WHEN dst_kind = 'video' AND height = 480 THEN 0 " +
@@ -265,15 +285,14 @@ func transcodePending() {
srcFilepath := filepath.Join(config.GetDataDir(), srcVideo.Filename) srcFilepath := filepath.Join(config.GetDataDir(), srcVideo.Filename)
if trans.DstKind == "video" { if trans.DstKind == "video" {
videoToVideo(trans.ID, trans.Height, trans.FPS, srcFilepath) go videoToVideo(sem, trans.ID, srcFilepath)
} else if trans.DstKind == "audio" { } else if trans.DstKind == "audio" {
videoToAudio(trans.ID, trans.Rate, srcFilepath) go videoToAudio(sem, trans.ID, srcFilepath)
} else { } else {
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" { } else if trans.SrcKind == "audio" {
var srcAudio media.Audio var srcAudio media.Audio
err = db.First(&srcAudio, "id = ?", trans.SrcID).Error err = db.First(&srcAudio, "id = ?", trans.SrcID).Error
if err != nil { if err != nil {
@@ -282,7 +301,7 @@ func transcodePending() {
continue continue
} }
srcFilepath := filepath.Join(config.GetDataDir(), srcAudio.Filename) srcFilepath := filepath.Join(config.GetDataDir(), srcAudio.Filename)
audioToAudio(trans.ID, trans.Rate, srcFilepath) go audioToAudio(sem, trans.ID, 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)
@@ -290,11 +309,3 @@ func transcodePending() {
} }
} }
func transcodeWorker() {
transcodePending()
ticker := time.NewTicker(10 * time.Second)
for range ticker.C {
transcodePending()
}
}