commit 1720727c9e99e70e71d23b1bd4beeeb4dcb99f2b Author: Carl Pearson Date: Fri Sep 6 10:47:41 2024 -0600 Initial commit 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