Initial commit
This commit is contained in:
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
downloads
|
||||||
|
config
|
||||||
|
go.sum
|
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
downloads
|
||||||
|
config
|
||||||
|
go.sum
|
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -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"]
|
35
README.md
Normal file
35
README.md
Normal file
@@ -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
|
||||||
|
```
|
41
config.go
Normal file
41
config.go
Normal file
@@ -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))
|
||||||
|
}
|
30
go.mod
Normal file
30
go.mod
Normal file
@@ -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
|
||||||
|
)
|
236
handlers.go
Normal file
236
handlers.go
Normal file
@@ -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")
|
||||||
|
}
|
116
main.go
Normal file
116
main.go
Normal file
@@ -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)
|
||||||
|
}
|
28
middleware.go
Normal file
28
middleware.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
80
models.go
Normal file
80
models.go
Normal file
@@ -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)
|
||||||
|
}
|
18
templates/download.html
Normal file
18
templates/download.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>Download Video</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1>Download Video</h1>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="url" name="url" placeholder="Video URL" required>
|
||||||
|
<button type="submit">Download</button>
|
||||||
|
</form>
|
||||||
|
<a href="/videos">View Downloaded Videos</a>
|
||||||
|
<a href="/logout">Logout</a>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
14
templates/home.html
Normal file
14
templates/home.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>Video Downloader</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1>Welcome to Video Downloader</h1>
|
||||||
|
<a href="/login">Login</a>
|
||||||
|
<a href="/register">Register</a>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
17
templates/login.html
Normal file
17
templates/login.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>Login</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1>Login</h1>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="text" name="username" placeholder="Username" required>
|
||||||
|
<input type="password" name="password" placeholder="Password" required>
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
17
templates/register.html
Normal file
17
templates/register.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>Register</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1>Register</h1>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="text" name="username" placeholder="Username" required>
|
||||||
|
<input type="password" name="password" placeholder="Password" required>
|
||||||
|
<button type="submit">Register</button>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
64
templates/videos.html
Normal file
64
templates/videos.html
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>Downloaded Videos</title>
|
||||||
|
<meta http-equiv="refresh" content="5">
|
||||||
|
<style>
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1>Downloaded Videos</h1>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>URL</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
{{range .videos}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.URL}}</td>
|
||||||
|
<td>{{.Title}}</td>
|
||||||
|
<td>{{.Status}}</td>
|
||||||
|
<td>
|
||||||
|
{{if eq .Status "completed"}}
|
||||||
|
<a href="/downloads/video/{{.VideoFilename}}">Download Video</a> |
|
||||||
|
<a href="/downloads/audio/{{.AudioFilename}}">Download Audio</a>
|
||||||
|
{{else if eq .Status "failed"}}
|
||||||
|
<form action="/video/{{.ID}}/restart" method="post" style="display:inline;">
|
||||||
|
<button type="submit">Restart</button>
|
||||||
|
</form>
|
||||||
|
{{else if eq .Status "downloading"}}
|
||||||
|
<form action="/video/{{.ID}}/cancel" method="post" style="display:inline;">
|
||||||
|
<button type="submit">Cancel</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
<form action="/video/{{.ID}}/delete" method="post" style="display:inline;">
|
||||||
|
<button type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</table>
|
||||||
|
<p><a href="/download">Download New Video</a></p>
|
||||||
|
<p><a href="/logout">Logout</a></p>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
Reference in New Issue
Block a user