From 1720727c9e99e70e71d23b1bd4beeeb4dcb99f2b Mon Sep 17 00:00:00 2001 From: Carl Pearson Date: Fri, 6 Sep 2024 10:47:41 -0600 Subject: [PATCH] Initial commit --- .dockerignore | 3 + .gitignore | 3 + Dockerfile | 19 ++++ README.md | 35 ++++++ config.go | 41 +++++++ go.mod | 30 +++++ handlers.go | 236 ++++++++++++++++++++++++++++++++++++++++ main.go | 116 ++++++++++++++++++++ middleware.go | 28 +++++ models.go | 80 ++++++++++++++ templates/download.html | 18 +++ templates/home.html | 14 +++ templates/login.html | 17 +++ templates/register.html | 17 +++ templates/videos.html | 64 +++++++++++ 15 files changed, 721 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 config.go create mode 100644 go.mod create mode 100644 handlers.go create mode 100644 main.go create mode 100644 middleware.go create mode 100644 models.go create mode 100644 templates/download.html create mode 100644 templates/home.html create mode 100644 templates/login.html create mode 100644 templates/register.html create mode 100644 templates/videos.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d342095 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +downloads +config +go.sum \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d342095 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +downloads +config +go.sum \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0c2e2a2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM golang:1.23.0-bookworm as builder + +ADD *.go /src/. +ADD go.mod /src + +RUN cd /src && go mod tidy +RUN cd /src && go build -o server *.go + +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y ffmpeg wget +RUN wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux -O /usr/local/bin/yt-dlp \ + && chmod +x /usr/local/bin/yt-dlp + +COPY --from=0 /src/server /opt/server +ADD templates /opt/templates +WORKDIR /opt + +CMD ["/opt/server"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b423811 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# ytdlp-site + +``` +go mod tidy +``` + +```bash +export YTDLP_SITE_ADMIN_INITIAL_PASSWORD=abc123 +export YTDLP_SITE_SESSION_AUTH_KEY=v9qpt37hc4qpmhf +go run *.go +``` + +## Environment Variables + +* `YTDLP_SITE_ADMIN_INITIAL_PASSWORD`: password of the `admin` account, if the account does not exist +* `YTDLP_SITE_SESSION_AUTH_KEY`: admin-selected secret key for the cookie store + +## Docker + +```bash +docker build -t server . + +docker run --rm -it \ + -p 3000:8080 \ + --env YTDLP_SITE_ADMIN_INITIAL_PASSWORD=abc123 \ + server + +docker run --rm -it \ + -p 3000:8080 \ + --env YTDLP_SITE_DOWNLOAD_DIR=/downloads \ + --env YTDLP_SITE_CONFIG_DIR=/config \ + --env YTDLP_SITE_ADMIN_INITIAL_PASSWORD=abc123 \ + -v $(realpath downloads):/downloads \ + server +``` \ No newline at end of file diff --git a/config.go b/config.go new file mode 100644 index 0000000..882bfa6 --- /dev/null +++ b/config.go @@ -0,0 +1,41 @@ +package main + +import ( + "errors" + "fmt" + "os" +) + +func getDownloadDir() string { + value, exists := os.LookupEnv("YTDLP_SITE_DOWNLOAD_DIR") + if exists { + return value + } + return "downloads" +} + +func getConfigDir() string { + value, exists := os.LookupEnv("YTDLP_SITE_CONFIG_DIR") + if exists { + return value + } + return "config" +} + +func getAdminInitialPassword() (string, error) { + key := "YTDLP_SITE_ADMIN_INITIAL_PASSWORD" + value, exists := os.LookupEnv(key) + if exists { + return value, nil + } + return "", errors.New(fmt.Sprintf("please set %s", key)) +} + +func getSessionAuthKey() ([]byte, error) { + key := "YTDLP_SITE_SESSION_AUTH_KEY" + value, exists := os.LookupEnv(key) + if exists { + return []byte(value), nil + } + return []byte{}, errors.New(fmt.Sprintf("please set %s", key)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8e77686 --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module ytdlp-site + +go 1.23 + +toolchain go1.23.0 + +require ( + github.com/gorilla/sessions v1.4.0 + github.com/labstack/echo/v4 v4.10.2 + golang.org/x/crypto v0.9.0 + gorm.io/driver/sqlite v1.5.1 + gorm.io/gorm v1.25.1 +) + +require ( + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/labstack/gommon v0.4.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/mattn/go-sqlite3 v1.14.16 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/net v0.10.0 // indirect + golang.org/x/sys v0.8.0 // indirect + golang.org/x/text v0.9.0 // indirect + golang.org/x/time v0.3.0 // indirect +) diff --git a/handlers.go b/handlers.go new file mode 100644 index 0000000..87dab72 --- /dev/null +++ b/handlers.go @@ -0,0 +1,236 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "github.com/labstack/echo/v4" + "golang.org/x/crypto/bcrypt" +) + +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") + session.Values["user_id"] = nil + 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) + + video := Video{URL: url, UserID: userID, Status: "pending"} + db.Create(&video) + + go startDownload(video.ID, url) + + return c.Redirect(http.StatusSeeOther, "/videos") +} + +type Meta struct { + title string + ext string +} + +func getMeta(url string) (Meta, error) { + cmd := exec.Command("yt-dlp", "--simulate", "--print", "%(title)s.%(ext)s", url) + + var stdout bytes.Buffer + cmd.Stdout = &stdout + err := cmd.Run() + if err != nil { + fmt.Println("getTitle error:", err) + return Meta{}, err + } else { + + isDot := func(r rune) bool { + return r == '.' + } + + fields := strings.FieldsFunc(strings.TrimSpace(stdout.String()), isDot) + if len(fields) < 2 { + return Meta{}, errors.New("couldn't parse ytdlp output") + } + + return Meta{ + title: strings.Join(fields[:len(fields)-1], "."), + ext: fields[len(fields)-1], + }, nil + } +} + +func startDownload(videoID uint, videoURL string) { + db.Model(&Video{}).Where("id = ?", videoID).Update("status", "downloading") + + meta, err := getMeta(videoURL) + if err != nil { + db.Model(&Video{}).Where("id = ?", videoID).Update("status", "failed") + return + } + fmt.Println("set video title:", meta.title) + db.Model(&Video{}).Where("id = ?", videoID).Update("title", meta.title) + + videoFilename := fmt.Sprintf("%d-%s.%s", videoID, meta.title, meta.ext) + videoFilepath := filepath.Join(getDownloadDir(), "video", videoFilename) + cmd := exec.Command("yt-dlp", "-o", videoFilepath, videoURL) + err = cmd.Run() + if err != nil { + db.Model(&Video{}).Where("id = ?", videoID).Update("status", "failed") + return + } + + audioFilename := fmt.Sprintf("%d-%s.mp3", videoID, meta.title) + audioFilepath := filepath.Join(getDownloadDir(), "audio", audioFilename) + audioDir := filepath.Dir(audioFilepath) + fmt.Println("Create", audioDir) + err = os.MkdirAll(audioDir, 0700) + if err != nil { + fmt.Println("Error: couldn't create", audioDir) + db.Model(&Video{}).Where("id = ?", videoID).Update("status", "failed") + return + } + ffmpeg := "ffmpeg" + ffmpegArgs := []string{"-i", videoFilepath, "-vn", "-acodec", + "mp3", "-b:a", "192k", audioFilepath} + fmt.Println(ffmpeg, ffmpegArgs) + cmd = exec.Command(ffmpeg, ffmpegArgs...) + err = cmd.Run() + if err != nil { + fmt.Println("Error: convert to audio file", videoFilepath, "->", audioFilepath) + db.Model(&Video{}).Where("id = ?", videoID).Update("status", "failed") + return + } + + // FIXME: ensure expected files exist + db.Model(&Video{}).Where("id = ?", videoID).Updates(map[string]interface{}{ + "video_filename": videoFilename, + "audio_filename": audioFilename, + "status": "completed", + }) +} + +func videosHandler(c echo.Context) error { + userID := c.Get("user_id").(uint) + var videos []Video + db.Where("user_id = ?", userID).Find(&videos) + return c.Render(http.StatusOK, "videos.html", map[string]interface{}{"videos": videos}) +} + +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) + video.Status = "cancelled" + db.Save(&video) + + return c.Redirect(http.StatusSeeOther, "/videos") +} + +func videoRestartHandler(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") + } + + video.Status = "pending" + db.Save(&video) + go startDownload(uint(id), video.URL) + + return c.Redirect(http.StatusSeeOther, "/videos") +} + +func videoDeleteHandler(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") + } + + // Delete the file + if video.VideoFilename != "" { + os.Remove(filepath.Join(getDownloadDir(), "video", video.VideoFilename)) + } + if video.AudioFilename != "" { + os.Remove(filepath.Join(getDownloadDir(), "audio", video.AudioFilename)) + } + + // Delete from database + db.Delete(&video) + + return c.Redirect(http.StatusSeeOther, "/videos") +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..c739ca8 --- /dev/null +++ b/main.go @@ -0,0 +1,116 @@ +package main + +import ( + "fmt" + "html/template" + "io" + "os" + "path/filepath" + + "github.com/gorilla/sessions" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +var db *gorm.DB + +func ensureAdminAccount(db *gorm.DB) error { + + var user User + if err := db.Where("username = ?", "admin").First(&user).Error; err != nil { + // no such user + + password, err := getAdminInitialPassword() + if err != nil { + return err + } + + err = CreateUser(db, "admin", password) + if err != nil { + return err + } + } + return nil +} + +func main() { + + // Create config database + err := os.MkdirAll(getConfigDir(), 0700) + if err != nil { + panic("failed to create config dir") + } + + // Initialize database + dbPath := filepath.Join(getConfigDir(), "videos.db") + db, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + if err != nil { + panic("failed to connect database") + } + + // Migrate the schema + db.AutoMigrate(&Video{}, &User{}) + + // create a user + // FIXME: only if this user doesn't exist + err = ensureAdminAccount(db) + if err != nil { + panic(fmt.Sprintf("failed to create admin user: %v", err)) + } + + // create the cookie store + key, err := getSessionAuthKey() + if err != nil { + panic(fmt.Sprintf("%v", err)) + } + store = sessions.NewCookieStore(key) + + // Initialize Echo + e := echo.New() + + // Middleware + e.Use(middleware.Logger()) + e.Use(middleware.Recover()) + + // Templates + t := &Template{ + templates: template.Must(template.ParseGlob("templates/*.html")), + } + e.Renderer = t + + // Routes + e.GET("/", homeHandler) + e.GET("/login", loginHandler) + e.POST("/login", loginPostHandler) + e.GET("/register", registerHandler) + e.POST("/register", registerPostHandler) + e.GET("/logout", logoutHandler) + e.GET("/download", downloadHandler, authMiddleware) + e.POST("/download", downloadPostHandler, authMiddleware) + e.GET("/videos", videosHandler, authMiddleware) + e.POST("/video/:id/cancel", videoCancelHandler, authMiddleware) + e.POST("/video/:id/restart", videoRestartHandler, authMiddleware) + e.POST("/video/:id/delete", videoDeleteHandler, authMiddleware) + e.Static("/downloads", "downloads") + + store.Options = &sessions.Options{ + Path: "/", + MaxAge: 60 * 15, + HttpOnly: true, + Secure: false, // needed for session to work over http + } + + // Start server + e.Logger.Fatal(e.Start(":8080")) +} + +// Template renderer +type Template struct { + templates *template.Template +} + +func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error { + return t.templates.ExecuteTemplate(w, name, data) +} diff --git a/middleware.go b/middleware.go new file mode 100644 index 0000000..9648d24 --- /dev/null +++ b/middleware.go @@ -0,0 +1,28 @@ +package main + +import ( + "fmt" + "net/http" + + "github.com/gorilla/sessions" + "github.com/labstack/echo/v4" +) + +var store *sessions.CookieStore + +func authMiddleware(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + session, err := store.Get(c.Request(), "session") + if err != nil { + return c.String(http.StatusInternalServerError, "Error: Unable to retrieve session") + } + userID, ok := session.Values["user_id"] + if !ok { + fmt.Println("authMiddleware: session does not contain user_id. Redirect to /login") + return c.Redirect(http.StatusSeeOther, "/login") + } + fmt.Println("set user_id", userID, "in context") + c.Set("user_id", userID) + return next(c) + } +} diff --git a/models.go b/models.go new file mode 100644 index 0000000..0872499 --- /dev/null +++ b/models.go @@ -0,0 +1,80 @@ +package main + +import ( + "sync" + "time" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type Video struct { + gorm.Model + URL string + Title string + VideoFilename string + AudioFilename string + UserID uint + Status string // "pending", "downloading", "completed", "failed", "cancelled" +} + +type User struct { + gorm.Model + Username string `gorm:"unique"` + Password string +} + +type DownloadStatus struct { + ID uint + Progress float64 + Status string + Error string + StartTime time.Time +} + +type DownloadManager struct { + downloads map[uint]*DownloadStatus + mutex sync.RWMutex +} + +func CreateUser(db *gorm.DB, username, password string) error { + hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + user := User{Username: username, Password: string(hashedPassword)} + if err := db.Create(&user).Error; err != nil { + return err + } + return nil +} + +func NewDownloadManager() *DownloadManager { + return &DownloadManager{ + downloads: make(map[uint]*DownloadStatus), + } +} + +func (dm *DownloadManager) UpdateStatus(id uint, progress float64, status string, err string) { + dm.mutex.Lock() + defer dm.mutex.Unlock() + if _, exists := dm.downloads[id]; !exists { + dm.downloads[id] = &DownloadStatus{ID: id, StartTime: time.Now()} + } + dm.downloads[id].Progress = progress + dm.downloads[id].Status = status + dm.downloads[id].Error = err +} + +func (dm *DownloadManager) GetStatus(id uint) (DownloadStatus, bool) { + dm.mutex.RLock() + defer dm.mutex.RUnlock() + status, exists := dm.downloads[id] + if !exists { + return DownloadStatus{}, false + } + return *status, true +} + +func (dm *DownloadManager) RemoveStatus(id uint) { + dm.mutex.Lock() + defer dm.mutex.Unlock() + delete(dm.downloads, id) +} diff --git a/templates/download.html b/templates/download.html new file mode 100644 index 0000000..c53e58c --- /dev/null +++ b/templates/download.html @@ -0,0 +1,18 @@ + + + + + Download Video + + + +

Download Video

+
+ + +
+ View Downloaded Videos + Logout + + + \ No newline at end of file diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..65345d1 --- /dev/null +++ b/templates/home.html @@ -0,0 +1,14 @@ + + + + + Video Downloader + + + +

Welcome to Video Downloader

+ Login + Register + + + \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..c7b9499 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,17 @@ + + + + + Login + + + +

Login

+
+ + + +
+ + + \ No newline at end of file diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..a1593e7 --- /dev/null +++ b/templates/register.html @@ -0,0 +1,17 @@ + + + + + Register + + + +

Register

+
+ + + +
+ + + \ No newline at end of file diff --git a/templates/videos.html b/templates/videos.html new file mode 100644 index 0000000..8885aa6 --- /dev/null +++ b/templates/videos.html @@ -0,0 +1,64 @@ + + + + + Downloaded Videos + + + + + +

Downloaded Videos

+ + + + + + + + {{range .videos}} + + + + + + + {{end}} +
URLTitleStatusActions
{{.URL}}{{.Title}}{{.Status}} + {{if eq .Status "completed"}} + Download Video | + Download Audio + {{else if eq .Status "failed"}} +
+ +
+ {{else if eq .Status "downloading"}} +
+ +
+ {{end}} +
+ +
+
+

Download New Video

+

Logout

+ + + \ No newline at end of file