package config
import (
"os"
"path/filepath"
)
func (cfg *Config) EnsureTreePresent() error {
folders := []string{
"clips",
"movies",
"videos",
"actors",
"triage",
}
// ensure library folder exists
path := cfg.Media.Path
_, err := os.Stat(path)
if os.IsNotExist(err) {
// do not exists, create it
err = os.Mkdir(path, os.ModePerm)
if err != nil {
return err
}
} else if err != nil {
return err
}
// ensure folders inside the library exist
for _, folder := range folders {
path := filepath.Join(cfg.Media.Path, folder)
// ensure folder exists
_, err := os.Stat(path)
if os.IsNotExist(err) {
// do not exists, create it
err = os.Mkdir(path, os.ModePerm)
if err != nil {
return err
}
} else if err != nil {
return err
}
}
return nil
}
package config
import (
"errors"
"os"
"github.com/kelseyhightower/envconfig"
"gopkg.in/yaml.v3"
)
var ErrNoDbDriverSet = errors.New("ZT_DB_DRIVER is not set")
var ErrNoDbConnStringSet = errors.New("ZT_DB_CONNSTRING is not set")
var ErrNoMediaPathSet = errors.New("ZT_MEDIA_PATH is not set")
type Config struct {
Server struct {
Bind string `yaml:"bind" envconfig:"ZT_SERVER_BIND"`
}
DB struct {
Driver string `yaml:"driver" envconfig:"ZT_DB_DRIVER"`
Connstring string `yaml:"connstring" envconfig:"ZT_DB_CONNSTRING"`
} `yaml:"db"`
Media struct {
Path string `yaml:"path" envconfig:"ZT_MEDIA_PATH"`
} `yaml:"media"`
}
func New(configPath string) (*Config, error) {
cfg := &Config{}
if _, err := os.Stat(configPath); err == nil {
f, err := os.Open(configPath)
if err != nil {
return cfg, err
}
defer f.Close()
decoder := yaml.NewDecoder(f)
err = decoder.Decode(cfg)
if err != nil {
return cfg, err
}
}
err := envconfig.Process("zt", cfg)
if err != nil {
return cfg, err
}
// pre flight checks
if cfg.DB.Driver == "" {
return cfg, ErrNoDbDriverSet
}
if cfg.DB.Connstring == "" {
return cfg, ErrNoDbConnStringSet
}
if cfg.Media.Path == "" {
return cfg, ErrNoMediaPathSet
}
if cfg.Server.Bind == "" {
cfg.Server.Bind = "127.0.0.1:8080"
}
return cfg, nil
}
package controller
import (
"path/filepath"
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/model"
)
func (c *Controller) ActorAjaxNew(g *gin.Context) {
var err error
form := struct {
ID string `form:"id"`
Name string `form:"name"`
SexEnum string `form:"sex"`
}{}
err = g.ShouldBind(&form)
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
actor := &model.Actor{
ID: form.ID,
Name: form.Name,
Sex: form.SexEnum,
}
err = c.datastore.Create(&actor).Error
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
g.JSON(200, gin.H{
"result": actor.ID,
})
}
func (c *Controller) ActorAjaxProviderSearch(g *gin.Context) {
// get actor id from path
id := g.Param("id")
// get actor from ID
actor := &model.Actor{
ID: id,
}
result := c.datastore.First(actor)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
// get provider slug from path
provider_slug := g.Param("provider_slug")
provider, err := c.ProviderGet(provider_slug)
if err != nil {
g.JSON(404, gin.H{
"error": err.Error(),
"error_human": "Unable to retrieve provider",
})
return
}
url, err := provider.ActorSearch(actor.Name)
if err != nil {
g.JSON(404, gin.H{
"error": err.Error(),
"error_human": "Provider did not found a result",
})
return
}
// url found, storing it
link := &model.ActorLink{
Actor: *actor,
Provider: provider_slug,
URL: url,
}
err = c.datastore.Create(link).Error
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
g.JSON(200, gin.H{
"link_id": link.ID,
"link_url": url,
})
}
func (c *Controller) ActorAjaxLinkThumbGet(g *gin.Context) {
// get actor id from path
id := g.Param("id")
// get actor from ID
link := &model.ActorLink{
ID: id,
}
result := c.datastore.Preload("Actor").First(link)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
// get provider slug from path
provider, err := c.ProviderGet(link.Provider)
if err != nil {
g.JSON(404, gin.H{
"error": err.Error(),
"error_human": "Unable to retrieve provider",
})
return
}
thumb, err := provider.ActorGetThumb(link.Actor.Name, link.URL)
if err != nil {
g.JSON(404, gin.H{
"error": err.Error(),
"error_human": "Provider did not found a result",
})
return
}
g.Data(200, "image/png", thumb)
}
func (c *Controller) ActorAjaxLinkThumbDelete(g *gin.Context) {
// get actor id from path
id := g.Param("id")
// get actor from ID
link := &model.ActorLink{
ID: id,
}
result := c.datastore.Preload("Actor").First(link)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
err := c.datastore.Delete(&link).Error
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
"human_error": "unable to delete actor link",
})
return
}
g.JSON(200, gin.H{})
}
func (c *Controller) ActorAjaxThumb(g *gin.Context) {
// get id from path
id := g.Param("id")
// get item from ID
actor := &model.Actor{
ID: id,
}
result := c.datastore.First(actor)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
file, err := g.FormFile("pp")
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
// construct file path
targetPath := filepath.Join(c.config.Media.Path, ACTOR_FILEPATH, id, "thumb.jpg")
//save thumb on disk
err = g.SaveUploadedFile(file, targetPath)
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
// check if thumbnail exists
if !actor.Thumbnail {
actor.Thumbnail = true
err = c.datastore.Save(actor).Error
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
}
// all good
g.JSON(200, gin.H{})
}
func (c *Controller) ActorAjaxLinkCreate(g *gin.Context) {
var err error
form := struct {
URL string `form:"url"`
Provider string `form:"provider"`
}{}
err = g.ShouldBind(&form)
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
// get actor id from path
id := g.Param("id")
// get actor from ID
actor := &model.Actor{
ID: id,
}
result := c.datastore.First(actor)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
// get provider slug from path
_, err = c.ProviderGet(form.Provider)
if err != nil {
g.JSON(404, gin.H{
"error": err.Error(),
"error_human": "Unable to retrieve provider",
})
return
}
// url found, storing it
link := &model.ActorLink{
Actor: *actor,
Provider: form.Provider,
URL: form.URL,
}
err = c.datastore.Create(link).Error
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
g.JSON(200, gin.H{
"link_id": link.ID,
"link_url": link.URL,
})
}
func (c *Controller) ActorAjaxAliasCreate(g *gin.Context) {
var err error
form := struct {
Alias string `form:"alias"`
}{}
err = g.ShouldBind(&form)
if err != nil {
g.JSON(400, gin.H{
"error": err.Error(),
})
return
}
// get actor id from path
actorID := g.Param("id")
alias := model.ActorAlias{
Name: form.Alias,
ActorID: actorID,
}
err = c.datastore.Create(&alias).Error
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
g.JSON(200, gin.H{
"id": alias.ID,
})
}
func (c *Controller) ActorAjaxAliasRemove(g *gin.Context) {
var err error
// get alias id from path
aliasID := g.Param("id")
alias := model.ActorAlias{
ID: aliasID,
}
result := c.datastore.First(&alias)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
err = c.datastore.Delete(&alias).Error
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
g.JSON(200, gin.H{})
}
package controller
import (
"net/http"
"os"
"path/filepath"
"github.com/gin-gonic/gin"
"gorm.io/gorm/clause"
"github.com/zobtube/zobtube/internal/model"
)
func (c *Controller) ActorList(g *gin.Context) {
var actors []model.Actor
c.datastore.Find(&actors).Limit(30).Offset(0).Order("created_at")
g.HTML(http.StatusOK, "actor/list.html", gin.H{
"Actors": actors,
"User": g.MustGet("user").(*model.User),
})
}
type ActorNewForm struct {
Name string `form:"name"`
SexEnum string `form:"sex"`
}
func (c *Controller) ActorNew(g *gin.Context) {
var err error
if g.Request.Method == "POST" {
var form ActorNewForm
err = g.ShouldBind(&form)
if err == nil {
actor := &model.Actor{
Name: form.Name,
Sex: form.SexEnum,
}
err = c.datastore.Create(&actor).Error
if err == nil {
g.Redirect(http.StatusFound, "/actor/"+actor.ID)
return
}
}
}
g.HTML(http.StatusOK, "actor/create.html", gin.H{
"User": g.MustGet("user").(*model.User),
"Error": err,
})
}
func (c *Controller) ActorView(g *gin.Context) {
// get id from path
id := g.Param("id")
// get item from ID
actor := &model.Actor{
ID: id,
}
result := c.datastore.Preload(clause.Associations).First(actor)
// check result
if result.RowsAffected < 1 {
//TODO: return to homepage
g.JSON(404, gin.H{})
return
}
g.HTML(http.StatusOK, "actor/view.html", gin.H{
"User": g.MustGet("user").(*model.User),
"Actor": actor,
})
}
func (c *Controller) ActorEdit(g *gin.Context) {
// get id from path
id := g.Param("id")
// get item from ID
actor := &model.Actor{
ID: id,
}
result := c.datastore.Preload("Links").Preload("Aliases").First(actor)
// check result
if result.RowsAffected < 1 {
//TODO: return to homepage
g.JSON(404, gin.H{})
return
}
g.HTML(http.StatusOK, "actor/edit.html", gin.H{
"User": g.MustGet("user").(*model.User),
"Actor": actor,
"Providers": c.providers,
})
}
func (c *Controller) ActorThumb(g *gin.Context) {
// get id from path
id := g.Param("id")
// get item from ID
actor := &model.Actor{
ID: id,
}
result := c.datastore.First(actor)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
// check if thumbnail exists
if !actor.Thumbnail {
g.Redirect(http.StatusFound, ACTOR_PROFILE_PICTURE_MISSING)
return
}
// construct file path
targetPath := filepath.Join(c.config.Media.Path, ACTOR_FILEPATH, id, "thumb.jpg")
// give file path
g.File(targetPath)
}
func (c *Controller) ActorDelete(g *gin.Context) {
// get id from path
id := g.Param("id")
// get item from ID
actor := &model.Actor{
ID: id,
}
result := c.datastore.First(actor)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
// delete thumb
thumbPath := filepath.Join(c.config.Media.Path, ACTOR_FILEPATH, id, "thumb.jpg")
_, err := os.Stat(thumbPath)
if err != nil && !os.IsNotExist(err) {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
if !os.IsNotExist(err) {
// exist, deleting it
err = os.Remove(thumbPath)
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
}
// delete folder
folderPath := filepath.Join(c.config.Media.Path, ACTOR_FILEPATH, id)
_, err = os.Stat(folderPath)
if err != nil && !os.IsNotExist(err) {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
if !os.IsNotExist(err) {
// exist, deleting it
err = os.Remove(folderPath)
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
}
// delete object
err = c.datastore.Delete(actor).Error
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
// all good
g.Redirect(http.StatusFound, "/actors")
}
package controller
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/model"
)
func (c *Controller) AdmHome(g *gin.Context) {
// get counts
var (
videoCount int64
actorCount int64
channelCount int64
userCount int64
)
c.datastore.Table("videos").Count(&videoCount)
c.datastore.Table("actors").Count(&actorCount)
c.datastore.Table("channels").Count(&channelCount)
c.datastore.Table("users").Count(&userCount)
var tasks []model.Task
c.datastore.Limit(5).Order("created_at DESC").Find(&tasks)
g.HTML(http.StatusOK, "adm/home.html", gin.H{
"User": g.MustGet("user").(*model.User),
"Build": c.build,
"VideoCount": videoCount,
"ActorCount": actorCount,
"ChannelCount": channelCount,
"UserCount": userCount,
"Tasks": tasks,
})
}
func (c *Controller) AdmVideoList(g *gin.Context) {
var videos []model.Video
c.datastore.Find(&videos)
g.HTML(http.StatusOK, "adm/object-list.html", gin.H{
"User": g.MustGet("user").(*model.User),
"ObjectName": "Video",
"Objects": videos,
})
}
func (c *Controller) AdmActorList(g *gin.Context) {
var actors []model.Actor
c.datastore.Find(&actors)
g.HTML(http.StatusOK, "adm/object-list.html", gin.H{
"User": g.MustGet("user").(*model.User),
"ObjectName": "Actor",
"Objects": actors,
})
}
func (c *Controller) AdmChannelList(g *gin.Context) {
var channels []model.Channel
c.datastore.Find(&channels)
g.HTML(http.StatusOK, "adm/object-list.html", gin.H{
"User": g.MustGet("user").(*model.User),
"ObjectName": "Channel",
"Objects": channels,
})
}
func (c *Controller) AdmTaskView(g *gin.Context) {
// get id from path
id := g.Param("id")
// get item from ID
task := &model.Task{
ID: id,
}
result := c.datastore.First(task)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
g.HTML(http.StatusOK, "adm/task-view.html", gin.H{
"User": g.MustGet("user").(*model.User),
"Task": task,
})
}
func (c *Controller) AdmTaskList(g *gin.Context) {
tasks := []model.Task{}
result := c.datastore.Find(&tasks)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
g.HTML(http.StatusOK, "adm/task-list.html", gin.H{
"User": g.MustGet("user").(*model.User),
"Tasks": tasks,
})
}
func (c *Controller) AdmTaskRetry(g *gin.Context) {
// get id from path
id := g.Param("id")
// get item from ID
task := &model.Task{
ID: id,
}
result := c.datastore.First(task)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
task.Status = model.TaskStatusTodo
err := c.datastore.Save(task).Error
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
c.runner.TaskRetry(task.Name)
taskURL := fmt.Sprintf("/adm/task/%s", task.ID)
g.Redirect(http.StatusFound, taskURL)
}
func (c *Controller) AdmUserList(g *gin.Context) {
var users []model.User
c.datastore.Find(&users)
g.HTML(http.StatusOK, "adm/user-list.html", gin.H{
"User": g.MustGet("user").(*model.User),
"Objects": users,
})
}
type AdmUserNewForm struct {
Username string `form:"username"`
Password string `form:"password"`
Admin string `form:"admin"`
}
func (c *Controller) AdmUserNew(g *gin.Context) {
var err error
if g.Request.Method == "POST" {
var form AdmUserNewForm
err = g.ShouldBind(&form)
if err == nil {
if form.Password == "" {
err = errors.New("password cannot be empty")
} else {
// get user
userExists := &model.User{}
result := c.datastore.First(userExists, "username = ?", form.Username)
if result.RowsAffected > 0 {
err = errors.New("username already taken")
} else {
now := time.Now()
passwordHex := sha256.Sum256([]byte(form.Password))
password := hex.EncodeToString(passwordHex[:])
newUser := &model.User{
Username: form.Username,
Admin: form.Admin != "",
CreatedAt: now,
UpdatedAt: now,
Password: password,
}
err = c.datastore.Create(&newUser).Error
if err == nil {
g.Redirect(http.StatusFound, "/adm/users")
return
}
}
}
}
}
g.HTML(http.StatusOK, "adm/user-new.html", gin.H{
"User": g.MustGet("user").(*model.User),
"Error": err,
})
}
func (c *Controller) AdmUserDelete(g *gin.Context) {
var err error
// get alias id from path
userID := g.Param("id")
user := model.User{
ID: userID,
}
result := c.datastore.First(&user)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
err = c.datastore.Delete(&user).Error
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
g.Redirect(http.StatusFound, "/adm/users")
}
package controller
import (
"crypto/sha256"
"encoding/hex"
"time"
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/model"
)
func (c *Controller) AuthLogin(g *gin.Context) {
// validate authentication
cookie, err := g.Cookie(cookieName)
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
}
// get session
session := &model.UserSession{
ID: cookie,
}
result := c.datastore.First(session)
// check result
if result.RowsAffected < 1 {
c.createSession(g)
g.JSON(401, gin.H{
"error": "invalid session",
})
return
}
// check validity
if session.ValidUntil.Before(time.Now()) {
// session expired, creating a new one
c.createSession(g)
g.JSON(401, gin.H{
"error": "session expired",
})
return
}
// retrieve user
username := g.PostForm("username")
user := &model.User{}
result = c.datastore.First(&user, "username = ?", username)
if result.RowsAffected < 1 {
g.JSON(401, gin.H{
"error": "auth failed - user not found",
})
return
}
// validate authentication
challengeHex := sha256.Sum256([]byte(session.ID + user.Password))
challenge := hex.EncodeToString(challengeHex[:])
if g.PostForm("password") != challenge {
g.JSON(401, gin.H{
"error": "auth failed - password",
})
return
}
// extend expiration
session.ValidUntil = time.Now().Add(sessionTimeValidated)
session.UserID = &user.ID
c.datastore.Save(session)
// set auth cookie
cookieMaxAge := int(sessionTimeValidated / time.Second)
g.SetCookie(cookieName, session.ID, cookieMaxAge, "/", "127.0.0.1:8080", cookieSecure, cookieHttpOnly)
g.JSON(200, gin.H{})
}
package controller
import (
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/zobtube/zobtube/internal/model"
)
const cookieName = "zt_auth"
const cookieSecure = false
const cookieHttpOnly = false
const sessionTimePending = 10 * time.Minute
const sessionTimeValidated = 24 * time.Hour
func (c *Controller) createSession(g *gin.Context) {
// create a short session
session := &model.UserSession{
ValidUntil: time.Now().Add(sessionTimePending),
}
c.datastore.Save(session)
// cookie not set, creating it
cookieMaxAge := int(sessionTimePending / time.Second)
g.SetCookie(cookieName, session.ID, cookieMaxAge, "/", "127.0.0.1:8080", cookieSecure, cookieHttpOnly)
}
func (c *Controller) GetSession(session *model.UserSession) *gorm.DB {
return c.datastore.First(session)
}
func (c *Controller) GetUser(user *model.User) *gorm.DB {
return c.datastore.First(user)
}
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/model"
)
func (c *Controller) AuthPage(g *gin.Context) {
_, err := g.Cookie(cookieName)
if err != nil {
c.createSession(g)
}
g.HTML(http.StatusOK, "auth/login.html", gin.H{})
}
func (c *Controller) AuthLogout(g *gin.Context) {
cookie, err := g.Cookie(cookieName)
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
}
// get session
session := &model.UserSession{
ID: cookie,
}
result := c.datastore.First(session)
// check result
if result.RowsAffected > 0 {
// if found, delete it
c.datastore.Delete(&session)
}
g.Redirect(http.StatusFound, "/auth")
}
package controller
import (
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/model"
)
func (c *Controller) ChannelAjaxList(g *gin.Context) {
// get item from ID
channels := []model.Channel{}
c.datastore.Find(&channels)
channelsJSON := make(map[string]string)
for _, channel := range channels {
channelsJSON[channel.ID] = channel.Name
}
g.JSON(200, gin.H{
"channels": channelsJSON,
})
}
package controller
import (
"net/http"
"path/filepath"
"github.com/gin-gonic/gin"
"gorm.io/gorm/clause"
"github.com/zobtube/zobtube/internal/model"
)
func (c *Controller) ChannelList(g *gin.Context) {
var channels []model.Channel
c.datastore.Find(&channels)
g.HTML(http.StatusOK, "channel/list.html", gin.H{
"Channels": channels,
"User": g.MustGet("user").(*model.User),
})
}
type ChannelNewForm struct {
Name string `form:"name"`
}
func (c *Controller) ChannelCreate(g *gin.Context) {
var err error
if g.Request.Method == "POST" {
var form ActorNewForm
err = g.ShouldBind(&form)
if err == nil {
channel := &model.Channel{
Name: form.Name,
}
err = c.datastore.Create(&channel).Error
if err == nil {
g.Redirect(http.StatusFound, "/channel/"+channel.ID)
return
}
}
}
g.HTML(http.StatusOK, "channel/create.html", gin.H{
"User": g.MustGet("user").(*model.User),
"Error": err,
})
}
func (c *Controller) ChannelView(g *gin.Context) {
// get id from path
id := g.Param("id")
// get item from ID
channel := &model.Channel{
ID: id,
}
result := c.datastore.Preload(clause.Associations).First(channel)
// check result
if result.RowsAffected < 1 {
//TODO: return to homepage
g.JSON(404, gin.H{})
return
}
var videos []model.Video
c.datastore.Where("channel_id = ?", channel.ID).Find(&videos)
g.HTML(http.StatusOK, "channel/view.html", gin.H{
"User": g.MustGet("user").(*model.User),
"Channel": channel,
"Videos": videos,
})
}
func (c *Controller) ChannelThumb(g *gin.Context) {
// get id from path
id := g.Param("id")
// get item from ID
channel := &model.Channel{
ID: id,
}
result := c.datastore.First(channel)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
// check if thumbnail exists
if !channel.Thumbnail {
g.Redirect(http.StatusFound, ACTOR_PROFILE_PICTURE_MISSING)
return
}
// construct file path
targetPath := filepath.Join(c.config.Media.Path, CHANNEL_FILEPATH, id, "thumb.jpg")
// give file path
g.File(targetPath)
}
func (c *Controller) ChannelEdit(g *gin.Context) {
// get id from path
id := g.Param("id")
// get item from ID
channel := &model.Channel{
ID: id,
}
result := c.datastore.First(channel)
// check result
if result.RowsAffected < 1 {
//TODO: return to homepage
g.JSON(404, gin.H{})
return
}
// construct file path
targetPath := filepath.Join(c.config.Media.Path, CHANNEL_FILEPATH, id, "thumb.jpg")
if g.Request.Method == "POST" {
file, err := g.FormFile("profile")
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
//save thumb on disk
err = g.SaveUploadedFile(file, targetPath)
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
channel.Thumbnail = true
err = c.datastore.Save(channel).Error
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
}
g.HTML(http.StatusOK, "channel/edit.html", gin.H{
"User": g.MustGet("user").(*model.User),
"Channel": channel,
})
}
package controller
import (
"log"
"time"
"github.com/zobtube/zobtube/internal/model"
)
func (c *Controller) CleanupRoutine() {
for {
// wait loop
time.Sleep(time.Minute)
c.sessionCleanup()
}
}
func (c *Controller) sessionCleanup() {
var sessions []model.UserSession
result := c.datastore.Find(&sessions)
err := result.Error
if err != nil {
log.Println("error while querying sessions")
log.Println(err.Error())
}
for _, session := range sessions {
if session.ValidUntil.Before(time.Now()) {
c.datastore.Delete(&session)
}
}
}
package controller
import (
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/model"
)
func (c *Controller) ErrUnauthorized(g *gin.Context) {
g.HTML(401, "err/unauthorized.html", gin.H{
"User": g.MustGet("user").(*model.User),
})
}
package controller
import (
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"os"
"time"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
"github.com/zobtube/zobtube/internal/config"
"github.com/zobtube/zobtube/internal/model"
)
type ConfigNewForm struct {
Bind string `form:"bind"`
Media string `form:"media-path"`
DBDriver string `form:"db-driver"`
DBConnString string `form:"db-connstring"`
}
func (c *Controller) FailsafeConfiguration(g *gin.Context) {
var err error
if g.Request.Method == "POST" {
var form ConfigNewForm
err = g.ShouldBind(&form)
if err == nil {
newConfig := &config.Config{}
newConfig.Server.Bind = form.Bind
newConfig.Media.Path = form.Media
newConfig.DB.Driver = form.DBDriver
newConfig.DB.Connstring = form.DBConnString
file, err := os.OpenFile("config.yml", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
if err == nil {
defer file.Close()
encoder := yaml.NewEncoder(file)
err = encoder.Encode(newConfig)
if err == nil {
c.Restart()
g.Redirect(http.StatusFound, "/")
return
}
}
}
}
g.HTML(http.StatusOK, "failsafe/config.html", gin.H{
"Error": err,
})
}
type UserNewForm struct {
Username string `form:"username"`
Password string `form:"password"`
}
func (c *Controller) FailsafeUser(g *gin.Context) {
var err error
if g.Request.Method == "POST" {
var form UserNewForm
err = g.ShouldBind(&form)
if err == nil {
if form.Password == "" {
err = errors.New("password cannot be empty")
} else {
now := time.Now()
passwordHex := sha256.Sum256([]byte(form.Password))
password := hex.EncodeToString(passwordHex[:])
admin := &model.User{
Username: form.Username,
Admin: true,
CreatedAt: now,
UpdatedAt: now,
Password: password,
}
err = c.datastore.Create(&admin).Error
if err == nil {
c.Restart()
g.Redirect(http.StatusFound, "/")
return
}
}
}
}
g.HTML(http.StatusOK, "failsafe/user.html", gin.H{
"Error": err,
})
}
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/model"
)
func (c *Controller) Home(g *gin.Context) {
// get videos
var videos []model.Video
c.datastore.Where("type = ?", "v").Order("created_at desc").Find(&videos)
g.HTML(http.StatusOK, "home/home.html", gin.H{
"User": g.MustGet("user").(*model.User),
"Videos": videos,
"VideoType": "video",
})
}
package controller
import (
"net/http"
"sort"
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/model"
)
func (c *Controller) ProfileView(g *gin.Context) {
// get user
user := g.MustGet("user").(*model.User)
// get user views
var videoViewsTop []model.VideoView
c.datastore.Where("user_id = ?", user.ID).Order("count desc").Limit(8).Preload("Video").Find(&videoViewsTop)
// count actors
var countPerActor = make(map[string]int)
var videoViewsAll []model.VideoView
c.datastore.Where("user_id = ?", user.ID).Find(&videoViewsAll) // get all views for user
type ActorResult struct { // create temporary type to hold actor ids
ActorID string
}
for _, videoView := range videoViewsAll {
var actors []ActorResult
c.datastore.Table("video_actors").Select("actor_id").Where("video_id = ?", videoView.VideoID).Scan(&actors)
for _, actor := range actors {
_, ok := countPerActor[actor.ActorID]
if ok {
countPerActor[actor.ActorID] = countPerActor[actor.ActorID] + videoView.Count
} else {
countPerActor[actor.ActorID] = videoView.Count
}
}
}
// sort actors
keys := make([]string, 0, len(countPerActor))
for k := range countPerActor {
keys = append(keys, k)
}
sort.SliceStable(keys, func(i, j int) bool {
return countPerActor[keys[i]] > countPerActor[keys[j]]
})
// create intermediate structure to hold count of actors
type ActorView struct {
Actor model.Actor
Count int
}
var actorViews []ActorView
actorLimit := 12
for _, k := range keys {
actorLimit--
if actorLimit < 0 {
break
}
actor := &model.Actor{
ID: k,
}
c.datastore.First(actor)
actorViews = append(actorViews, ActorView{
Actor: *actor,
Count: countPerActor[k],
})
}
// render page
g.HTML(http.StatusOK, "profile/view.html", gin.H{
"User": user,
"VideoViews": videoViewsTop,
"ActorViews": actorViews,
})
}
package controller
import (
"errors"
"github.com/zobtube/zobtube/internal/provider"
)
func (c *Controller) ProviderRegister(p provider.Provider) {
c.providers[p.SlugGet()] = p
}
func (c *Controller) ProviderGet(slug string) (p provider.Provider, err error) {
p, ok := c.providers[slug]
if ok {
return p, nil
}
return p, errors.New("provider not found")
}
package controller
func (c *Controller) Shutdown() {
c.shutdownChannel <- 1
}
func (c *Controller) Restart() {
c.shutdownChannel <- 2
}
package controller
import (
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/config"
"github.com/zobtube/zobtube/internal/model"
"github.com/zobtube/zobtube/internal/provider"
"github.com/zobtube/zobtube/internal/runner"
"gorm.io/gorm"
)
type AbtractController interface {
// Back office
AdmHome(*gin.Context)
AdmVideoList(*gin.Context)
AdmActorList(*gin.Context)
AdmChannelList(*gin.Context)
AdmTaskList(*gin.Context)
AdmTaskRetry(*gin.Context)
AdmTaskView(*gin.Context)
AdmUserList(*gin.Context)
AdmUserNew(*gin.Context)
AdmUserDelete(*gin.Context)
// Home
Home(*gin.Context)
// Auth
AuthPage(*gin.Context)
AuthLogin(*gin.Context)
AuthLogout(*gin.Context)
GetSession(*model.UserSession) *gorm.DB
GetUser(*model.User) *gorm.DB
// Actors
ActorAjaxLinkThumbGet(*gin.Context)
ActorAjaxLinkThumbDelete(*gin.Context)
ActorAjaxNew(*gin.Context)
ActorAjaxProviderSearch(*gin.Context)
ActorAjaxThumb(*gin.Context)
ActorAjaxLinkCreate(*gin.Context)
ActorAjaxAliasCreate(*gin.Context)
ActorAjaxAliasRemove(*gin.Context)
ActorEdit(*gin.Context)
ActorList(*gin.Context)
ActorNew(*gin.Context)
ActorView(*gin.Context)
ActorThumb(*gin.Context)
ActorDelete(*gin.Context)
// Video, used for Clips, Movies and Videos
VideoAjaxActors(*gin.Context)
VideoAjaxRename(*gin.Context)
VideoAjaxUpload(*gin.Context)
VideoAjaxUploadThumb(*gin.Context)
VideoAjaxCreate(*gin.Context)
VideoAjaxStreamInfo(*gin.Context)
VideoAjaxDelete(*gin.Context)
VideoAjaxMigrate(*gin.Context)
VideoAjaxGenerateThumbnail(*gin.Context)
VideoEdit(*gin.Context)
VideoAjaxEditChannel(*gin.Context)
VideoStream(*gin.Context)
VideoThumb(*gin.Context)
VideoThumbXS(*gin.Context)
VideoView(*gin.Context)
// Video Views
VideoViewAjaxIncrement(*gin.Context)
ClipList(*gin.Context)
MovieList(*gin.Context)
VideoList(*gin.Context)
GenericVideoList(string, *gin.Context)
// Channels
ChannelCreate(*gin.Context)
ChannelList(*gin.Context)
ChannelView(*gin.Context)
ChannelThumb(*gin.Context)
ChannelAjaxList(*gin.Context)
ChannelEdit(*gin.Context)
// Uploads
UploadTriage(*gin.Context)
UploadPreview(*gin.Context)
UploadImport(*gin.Context)
UploadAjaxTriageFolder(*gin.Context)
UploadAjaxTriageFile(*gin.Context)
UploadAjaxUploadFile(*gin.Context)
UploadAjaxDeleteFile(*gin.Context)
UploadAjaxFolderCreate(*gin.Context)
// Providers
ProviderRegister(provider.Provider)
ProviderGet(string) (provider.Provider, error)
// Error pages
ErrUnauthorized(*gin.Context)
// Init
ConfigurationRegister(*config.Config)
DatabaseRegister(*gorm.DB)
RunnerRegister(*runner.Runner)
// Cleanup
CleanupRoutine()
// Profile
ProfileView(*gin.Context)
// Failsafe
FailsafeConfiguration(*gin.Context)
FailsafeUser(*gin.Context)
// Build
BuildDetailsRegister(string, string, string)
}
type buildDetails struct {
Version string
Commit string
BuildDate string
}
type Controller struct {
config *config.Config
datastore *gorm.DB
providers map[string]provider.Provider
shutdownChannel chan<- int
runner *runner.Runner
build *buildDetails
}
func New(shutdownChannel chan int) AbtractController {
return &Controller{
providers: make(map[string]provider.Provider),
shutdownChannel: shutdownChannel,
}
}
func (c *Controller) ConfigurationRegister(cfg *config.Config) {
c.config = cfg
}
func (c *Controller) DatabaseRegister(db *gorm.DB) {
c.datastore = db
}
func (c *Controller) RunnerRegister(r *runner.Runner) {
c.runner = r
}
func (c *Controller) BuildDetailsRegister(version, commit, buildDate string) {
c.build = &buildDetails{
Version: version,
Commit: commit,
BuildDate: buildDate,
}
}
package controller
import (
"net/http"
"net/url"
"os"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
)
func (c *Controller) UploadPreview(g *gin.Context) {
filePathEncoded := g.Param("filepath")
filePath, err := url.QueryUnescape(filePathEncoded)
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
// construct file path
targetPath := filepath.Join(c.config.Media.Path, TRIAGE_FILEPATH, filePath)
// give file path
g.File(targetPath)
}
func (c *Controller) UploadAjaxTriageFolder(g *gin.Context) {
// get requested path
path := g.PostForm("path")
// list folders in triage path
folders, err := os.ReadDir(
filepath.Join(c.config.Media.Path, TRIAGE_FILEPATH, path),
)
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
items := make(map[string]int)
for _, folder := range folders {
// check type
entryPath := filepath.Join(
c.config.Media.Path,
TRIAGE_FILEPATH,
path,
folder.Name(),
)
stat, err := os.Stat(entryPath)
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
if !stat.IsDir() {
continue
}
dir, err := os.Open(entryPath)
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
defer dir.Close()
// list files
files, err := dir.Readdir(-1)
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
items[folder.Name()] = len(files)
}
g.JSON(http.StatusOK, gin.H{
"folders": items,
})
}
type FileInfo struct {
Size int64
LastModification time.Time
}
func (c *Controller) UploadAjaxTriageFile(g *gin.Context) {
// get requested path
path := g.PostForm("path")
// list folders in triage path
entries, err := os.ReadDir(
filepath.Join(c.config.Media.Path, TRIAGE_FILEPATH, path),
)
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
items := make(map[string]FileInfo)
for _, entry := range entries {
// check type
entryPath := filepath.Join(
c.config.Media.Path,
TRIAGE_FILEPATH,
path,
entry.Name(),
)
stat, err := os.Stat(entryPath)
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
if stat.IsDir() {
continue
}
items[entry.Name()] = FileInfo{
Size: stat.Size(),
LastModification: stat.ModTime(),
}
}
g.JSON(http.StatusOK, gin.H{
"files": items,
})
}
func (c *Controller) UploadAjaxUploadFile(g *gin.Context) {
// get file
file, err := g.FormFile("file")
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
// get path
_path := g.PostForm("path")
path := filepath.Join(c.config.Media.Path, TRIAGE_FILEPATH, _path, file.Filename)
// save file
err = g.SaveUploadedFile(file, path)
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
g.JSON(200, gin.H{})
}
func (c *Controller) UploadAjaxDeleteFile(g *gin.Context) {
// get file from request
type fileDeleteForm struct {
File string
}
form := fileDeleteForm{}
err := g.ShouldBind(&form)
if err != nil {
g.JSON(400, gin.H{
"error": err.Error(),
})
return
}
// ensure not empty
file := form.File
if file == "" {
g.JSON(400, gin.H{
"error": "file name cannot be empty",
})
return
}
// assemble with triage path
file = filepath.Join(c.config.Media.Path, TRIAGE_FILEPATH, file)
// remove file
err = os.Remove(file)
if err != nil {
g.JSON(422, gin.H{
"error": err.Error(),
})
return
}
g.JSON(200, gin.H{})
}
func (c *Controller) UploadAjaxFolderCreate(g *gin.Context) {
// get new folder name
name := g.PostForm("name")
// construct absolute path
path := filepath.Join(c.config.Media.Path, TRIAGE_FILEPATH, name)
// check if folder already exists
_, err := os.Stat(path)
if !os.IsNotExist(err) {
g.JSON(409, gin.H{
"error": "Folder already exists",
})
return
}
// do not exists, create it
err = os.Mkdir(path, os.ModePerm)
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
g.JSON(200, gin.H{})
}
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/model"
)
func (c *Controller) UploadTriage(g *gin.Context) {
g.HTML(http.StatusOK, "upload/home.html", gin.H{
"User": g.MustGet("user").(*model.User),
})
}
type UploadImportForm struct {
Path string `form:"path"`
ImportAs string `form:"import_as"`
}
func (c *Controller) UploadImport(g *gin.Context) {
var form UploadImportForm
err := g.ShouldBind(&form)
if err != nil {
g.Redirect(http.StatusBadRequest, "/upload/triage")
return
}
video := &model.Video{
Name: form.Path,
Filename: form.Path,
Thumbnail: false,
ThumbnailMini: false,
Type: form.ImportAs,
}
c.datastore.Create(video)
//TODO: check result
g.Redirect(http.StatusFound, "/video/"+video.ID)
}
package controller
import (
"errors"
"os"
"path/filepath"
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/model"
)
func (c *Controller) VideoAjaxActors(g *gin.Context) {
// get id from path
id := g.Param("id")
actor_id := g.Param("actor_id")
// get item from ID
video := &model.Video{
ID: id,
}
result := c.datastore.First(video)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
actor := &model.Actor{
ID: actor_id,
}
result = c.datastore.First(&actor)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
var res error
if g.Request.Method == "PUT" {
res = c.datastore.Model(video).Association("Actors").Append(actor)
} else {
res = c.datastore.Model(video).Association("Actors").Delete(actor)
}
if res != nil {
g.JSON(500, gin.H{})
return
}
g.JSON(200, gin.H{})
}
func (c *Controller) VideoAjaxStreamInfo(g *gin.Context) {
// get id from path
id := g.Param("id")
// get item from ID
video := &model.Video{
ID: id,
}
result := c.datastore.First(video)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
if !video.Imported {
g.JSON(404, gin.H{})
return
}
path := filepath.Join(c.config.Media.Path, video.RelativePath())
_, err := os.Stat(path)
if err == nil {
g.JSON(200, gin.H{})
return
} else if errors.Is(err, os.ErrNotExist) {
g.JSON(404, gin.H{})
return
} else {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
}
type VideoAjaxRenameForm struct {
Name string `form:"name"`
}
func (c *Controller) VideoAjaxRename(g *gin.Context) {
if g.Request.Method != "POST" {
// method not allowed
g.JSON(405, gin.H{})
return
}
var form VideoAjaxRenameForm
err := g.ShouldBind(&form)
if err != nil {
// method not allowed
g.JSON(406, gin.H{})
return
}
// get id from path
id := g.Param("id")
// get item from ID
video := &model.Video{
ID: id,
}
result := c.datastore.First(video)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
video.Name = form.Name
c.datastore.Save(video)
//TODO: check result
g.JSON(200, gin.H{})
}
func (c *Controller) VideoAjaxCreate(g *gin.Context) {
var err error
form := struct {
Name string `form:"name"`
Filename string `form:"filename"`
Actors []string `form:"actors"`
TypeEnum string `form:"type"`
}{}
err = g.ShouldBind(&form)
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
if form.Name == "" || form.Filename == "" || (form.TypeEnum != "c" && form.TypeEnum != "m" && form.TypeEnum != "v") {
g.JSON(500, gin.H{
"error": "invalid input",
})
return
}
video := &model.Video{
Name: form.Name,
Filename: form.Filename,
Type: form.TypeEnum,
Imported: false,
Thumbnail: false,
}
// save object in db
err = c.datastore.Create(&video).Error
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
for _, actorID := range form.Actors {
actor := &model.Actor{
ID: actorID,
}
result := c.datastore.First(&actor)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
err = c.datastore.Model(video).Association("Actors").Append(actor)
if err != nil {
c.datastore.Delete(&video)
g.JSON(500, gin.H{})
return
}
}
err = c.runner.NewTask("video/create", map[string]string{
"videoID": video.ID,
"thumbnailTiming": "0",
})
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
g.JSON(200, gin.H{
"video_id": video.ID,
})
}
func (c *Controller) VideoAjaxUploadThumb(g *gin.Context) {
// get id from path
id := g.Param("id")
// get item from ID
video := &model.Video{
ID: id,
}
result := c.datastore.First(video)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
// ensure folder exists
videoFolder := filepath.Join(c.config.Media.Path, video.FolderRelativePath())
_, err := os.Stat(videoFolder)
if os.IsNotExist(err) {
// do not exists, create it
err = os.Mkdir(videoFolder, os.ModePerm)
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
} else if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
// save thumbnail
thumbnailPath := video.ThumbnailRelativePath()
thumbnail, err := g.FormFile("thumbnail")
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
err = g.SaveUploadedFile(thumbnail, thumbnailPath)
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
// commit the update on database
video.Thumbnail = true
err = c.datastore.Save(video).Error
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
err = c.runner.NewTask("video/mini-thumb", map[string]string{"videoID": video.ID})
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
g.JSON(200, gin.H{})
}
func (c *Controller) VideoAjaxUpload(g *gin.Context) {
// get id from path
id := g.Param("id")
// get item from ID
video := &model.Video{
ID: id,
}
result := c.datastore.First(video)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
// ensure folder exists
videoFolder := filepath.Join(c.config.Media.Path, video.FolderRelativePath())
_, err := os.Stat(videoFolder)
if os.IsNotExist(err) {
// do not exists, create it
err = os.Mkdir(videoFolder, os.ModePerm)
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
} else if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
// save video
videoPath := filepath.Join(videoFolder, "video.mp4")
videoData, err := g.FormFile("file")
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
err = g.SaveUploadedFile(videoData, videoPath)
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
// commit the update on database
video.Imported = true
err = c.datastore.Save(video).Error
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
g.JSON(200, gin.H{})
}
func (c *Controller) VideoAjaxDelete(g *gin.Context) {
// get id from path
id := g.Param("id")
// get item from ID
video := &model.Video{
ID: id,
}
result := c.datastore.First(video)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
// update status
video.Status = model.VideoStatusDeleting
err := c.datastore.Save(video).Error
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
// create task
err = c.runner.NewTask("video/delete", map[string]string{
"videoID": video.ID,
})
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
g.JSON(200, gin.H{})
}
func (c *Controller) VideoAjaxMigrate(g *gin.Context) {
// get id from path
id := g.Param("id")
// get item from ID
video := &model.Video{
ID: id,
}
result := c.datastore.First(video)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
newType := g.PostForm("new_type")
previousPath := filepath.Join(c.config.Media.Path, video.RelativePath())
// change object in db
video.Type = newType
newPath := filepath.Join(c.config.Media.Path, video.RelativePath())
// move
err := os.Rename(previousPath, newPath)
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
// commit
err = c.datastore.Save(video).Error
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
g.JSON(200, gin.H{})
}
func (c *Controller) VideoAjaxGenerateThumbnail(g *gin.Context) {
// get id from path
id := g.Param("id")
// get item from ID
video := &model.Video{
ID: id,
}
result := c.datastore.First(video)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
if video.Status != model.VideoStatusReady {
g.JSON(409, gin.H{
"error": "video is not ready to be updated",
})
return
}
// create task
err := c.runner.NewTask("video/generate-thumbnail", map[string]string{
"videoID": video.ID,
"thumbnailTiming": g.Param("timing"),
})
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
g.JSON(200, gin.H{})
}
func (c *Controller) VideoAjaxEditChannel(g *gin.Context) {
if g.Request.Method != "POST" {
// method not allowed
g.JSON(405, gin.H{})
return
}
form := struct {
ChannelID string `form:"channelID"`
}{}
err := g.ShouldBind(&form)
if err != nil {
// method not allowed
g.JSON(406, gin.H{})
return
}
// get id from path
id := g.Param("id")
// get item from ID
video := &model.Video{
ID: id,
}
result := c.datastore.First(video)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
if form.ChannelID == "x" {
video.ChannelID = nil
} else {
channel := &model.Channel{
ID: form.ChannelID,
}
result := c.datastore.First(channel)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
video.Channel = channel
}
err = c.datastore.Save(video).Error
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
g.JSON(200, gin.H{})
}
package controller
import (
"net/http"
"path/filepath"
"github.com/gin-gonic/gin"
"gorm.io/gorm/clause"
"github.com/zobtube/zobtube/internal/model"
)
func (c *Controller) VideoEdit(g *gin.Context) {
// get id from path
id := g.Param("id")
// get item from ID
video := &model.Video{
ID: id,
}
result := c.datastore.Preload("Actors").Preload("Channel").First(video)
var actors []model.Actor
c.datastore.Find(&actors)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
g.HTML(http.StatusOK, "video/edit.html", gin.H{
"Actors": actors,
"User": g.MustGet("user").(*model.User),
"Video": video,
})
}
func (c *Controller) ClipList(g *gin.Context) {
var videos []model.Video
c.datastore.Where("type = ?", "c").Order("created_at desc").Preload(clause.Associations).Find(&videos)
g.HTML(http.StatusOK, "clip/list.html", gin.H{
"Type": "clip",
"User": g.MustGet("user").(*model.User),
"Videos": videos,
})
}
func (c *Controller) MovieList(g *gin.Context) {
var videos []model.Video
c.datastore.Where("type = ?", "m").Order("created_at desc").Preload(clause.Associations).Find(&videos)
g.HTML(http.StatusOK, "movie/list.html", gin.H{
"Type": "movie",
"User": g.MustGet("user").(*model.User),
"Videos": videos,
})
}
func (c *Controller) VideoList(g *gin.Context) {
c.GenericVideoList("video", g)
}
func (c *Controller) GenericVideoList(videoType string, g *gin.Context) {
var videos []model.Video
c.datastore.Where("type = ?", videoType[0:1]).Order("created_at desc").Find(&videos)
g.HTML(http.StatusOK, "video/list.html", gin.H{
"Type": videoType,
"User": g.MustGet("user").(*model.User),
"Videos": videos,
})
}
func (c *Controller) VideoView(g *gin.Context) {
// get id from path
id := g.Param("id")
// get item from ID
video := &model.Video{
ID: id,
}
result := c.datastore.Preload("Actors").Preload("Channel").First(video)
// check result
if result.RowsAffected < 1 {
//TODO: return to homepage
g.JSON(404, gin.H{})
return
}
// get random videos
var randomVideos []model.Video
c.datastore.Limit(8).Order("RANDOM()").Find(&randomVideos)
// get video count
user := g.MustGet("user").(*model.User)
viewCount := 0
count := &model.VideoView{}
result = c.datastore.First(&count, "video_id = ? AND user_id = ?", video.ID, user.ID)
if result.RowsAffected > 0 {
viewCount = count.Count
}
g.HTML(http.StatusOK, "video/view.html", gin.H{
"Type": video.Type,
"User": user,
"Video": video,
"ViewCount": viewCount,
"RandomVideos": gin.H{
"Videos": randomVideos,
"VideoType": video.Type,
},
})
}
func (c *Controller) VideoStream(g *gin.Context) {
// get id from path
id := g.Param("id")
// get item from ID
video := &model.Video{
ID: id,
}
result := c.datastore.First(video)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
// construct file path
var targetPath string
if video.Imported {
targetPath = filepath.Join(c.config.Media.Path, video.RelativePath())
} else {
targetPath = filepath.Join(c.config.Media.Path, TRIAGE_FILEPATH, video.Filename)
}
// give file path
g.File(targetPath)
}
func (c *Controller) VideoThumb(g *gin.Context) {
// get id from path
id := g.Param("id")
// get item from ID
video := &model.Video{
ID: id,
}
result := c.datastore.First(video)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
// check if thumbnail exists
if !video.Thumbnail {
g.JSON(404, gin.H{})
return
}
// construct file path
targetPath := filepath.Join(c.config.Media.Path, video.ThumbnailRelativePath())
// give file path
g.File(targetPath)
}
func (c *Controller) VideoThumbXS(g *gin.Context) {
// get id from path
id := g.Param("id")
// get item from ID
video := &model.Video{
ID: id,
}
result := c.datastore.First(video)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
// check if thumbnail exists
if !video.ThumbnailMini {
g.Redirect(http.StatusFound, VIDEO_THUMB_NOT_GENERATED)
return
}
// construct file path
targetPath := filepath.Join(c.config.Media.Path, video.ThumbnailXSRelativePath())
// give file path
g.File(targetPath)
}
package controller
import (
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/model"
)
func (c *Controller) VideoViewAjaxIncrement(g *gin.Context) {
// get id from path
id := g.Param("id")
// get item from ID
video := &model.Video{
ID: id,
}
result := c.datastore.First(video)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
user := g.MustGet("user").(*model.User)
count := &model.VideoView{}
result = c.datastore.First(&count, "video_id = ? AND user_id = ?", video.ID, user.ID)
// check result
if result.RowsAffected > 0 {
// already exists, increment
count.Count++
err := c.datastore.Save(count).Error
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
g.JSON(200, gin.H{"view-count": count.Count})
return
}
// new view, create item
count = &model.VideoView{
User: *user,
Video: *video,
Count: 1,
}
err := c.datastore.Create(count).Error
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
g.JSON(200, gin.H{"view-count": count.Count})
}
package http
import (
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/controller"
"github.com/zobtube/zobtube/internal/model"
)
func UserIsAdmin(c controller.AbtractController) gin.HandlerFunc {
return func(g *gin.Context) {
// get user
user := g.MustGet("user").(*model.User)
// check if admin
if !user.Admin {
g.Redirect(307, "/error/unauthorized")
g.Abort()
return
}
// all good
g.Next()
}
}
package http
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/controller"
"github.com/zobtube/zobtube/internal/model"
)
const cookieName = "zt_auth"
func UserIsAuthenticated(c controller.AbtractController) gin.HandlerFunc {
return func(g *gin.Context) {
cookie, err := g.Cookie(cookieName)
if err != nil {
// cookie not set
g.Redirect(http.StatusFound, "/auth")
g.Abort()
return
}
// get session
session := &model.UserSession{
ID: cookie,
}
result := c.GetSession(session)
// check result
if result.RowsAffected < 1 {
g.Redirect(http.StatusFound, "/auth")
g.Abort()
return
}
// check validity
if session.ValidUntil.Before(time.Now()) {
g.Redirect(http.StatusFound, "/auth")
g.Abort()
return
}
// check if user is authenticated
if session.UserID == nil || *session.UserID == "" {
g.Redirect(http.StatusFound, "/auth")
g.Abort()
return
}
// get user
user := &model.User{
ID: *session.UserID,
}
result = c.GetUser(user)
if result.RowsAffected < 1 {
g.Redirect(http.StatusFound, "/auth")
g.Abort()
return
}
// set meta in context
g.Set("user", user)
g.Next()
}
}
package http
import (
"embed"
"html/template"
"io/fs"
"regexp"
"strings"
)
func loadAndAddToRoot(funcMap template.FuncMap, rootTemplate *template.Template, embedFS embed.FS, pattern string) error {
pattern = strings.ReplaceAll(pattern, ".", "\\.")
pattern = strings.ReplaceAll(pattern, "*", ".*")
err := fs.WalkDir(embedFS, ".", func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if matched, _ := regexp.MatchString(pattern, path); !d.IsDir() && matched {
data, readErr := embedFS.ReadFile(path)
if readErr != nil {
return readErr
}
t := rootTemplate.New(path).Funcs(funcMap)
if _, parseErr := t.Parse(string(data)); parseErr != nil {
return parseErr
}
}
return nil
})
return err
}
func (s *Server) LoadHTMLFromEmbedFS(globPath string) {
root := template.New("")
tmpl := template.Must(
root,
loadAndAddToRoot(
s.Router.FuncMap,
root,
*s.FS,
globPath),
)
s.Router.SetHTMLTemplate(tmpl)
}
package http
import (
"net/http"
"github.com/gin-gonic/gin"
)
func livenessProbe(c *gin.Context) {
c.String(http.StatusOK, "alive")
}
package http
import (
"io/fs"
"net/http"
"github.com/zobtube/zobtube/internal/controller"
)
func (s *Server) setupRoutes(c controller.AbtractController) {
// load templates
s.LoadHTMLFromEmbedFS("web/page/**/*")
// prepare subfs
staticFS, _ := fs.Sub(s.FS, "web/static")
// load static
s.Router.StaticFS("/static", http.FS(staticFS))
s.Router.GET("/ping", livenessProbe)
// authentication
auth := s.Router.Group("/auth")
auth.GET("", c.AuthPage)
auth.POST("/login", c.AuthLogin)
auth.GET("/logout", c.AuthLogout)
authGroup := s.Router.Group("")
authGroup.Use(UserIsAuthenticated(c))
admGroup := s.Router.Group("")
admGroup.Use(UserIsAuthenticated(c))
admGroup.Use(UserIsAdmin(c))
// home
authGroup.GET("", c.Home)
// actors
authGroup.GET("/actors", c.ActorList)
admGroup.GET("/actor/new", c.ActorNew)
admGroup.POST("/actor/new", c.ActorNew)
authGroup.GET("/actor/:id", c.ActorView)
admGroup.GET("/actor/:id/edit", c.ActorEdit)
authGroup.GET("/actor/:id/thumb", c.ActorThumb)
admGroup.GET("/actor/:id/delete", c.ActorDelete)
actorAPI := admGroup.Group("/api/actor")
{
actorAPI.POST("/", c.ActorAjaxNew)
// providers
actorAPI.GET("/:id/provider/:provider_slug", c.ActorAjaxProviderSearch)
// links
actorAPI.DELETE("/link/:id", c.ActorAjaxLinkThumbDelete)
actorAPI.GET("/link/:id/thumb", c.ActorAjaxLinkThumbGet)
actorAPI.POST("/:id/link", c.ActorAjaxLinkCreate)
// thumb
actorAPI.POST("/:id/thumb", c.ActorAjaxThumb)
// alias
actorAPI.POST("/:id/alias", c.ActorAjaxAliasCreate)
actorAPI.DELETE("/alias/:id", c.ActorAjaxAliasRemove)
}
// channels
authGroup.GET("/channels", c.ChannelList)
admGroup.GET("/channel/new", c.ChannelCreate)
admGroup.POST("/channel/new", c.ChannelCreate)
authGroup.GET("/channel/:id", c.ChannelView)
authGroup.GET("/channel/:id/thumb", c.ChannelThumb)
admGroup.GET("/channel/:id/edit", c.ChannelEdit)
admGroup.POST("/channel/:id/edit", c.ChannelEdit)
// channels API
authGroup.GET("/api/channels", c.ChannelAjaxList)
// videos
authGroup.GET("/clips", c.ClipList)
authGroup.GET("/movies", c.MovieList)
authGroup.GET("/videos", c.VideoList)
authGroup.GET("/video/:id", c.VideoView)
admGroup.GET("/video/:id/edit", c.VideoEdit)
authGroup.GET("/video/:id/stream", c.VideoStream)
authGroup.GET("/video/:id/thumb", c.VideoThumb)
authGroup.GET("/video/:id/thumb_xs", c.VideoThumbXS)
// videos API
videoAPI := admGroup.Group("/api/video")
videoAPI.POST("", c.VideoAjaxCreate)
videoAPI.HEAD("/:id", c.VideoAjaxStreamInfo)
videoAPI.DELETE("/:id", c.VideoAjaxDelete)
videoAPI.POST("/:id/upload", c.VideoAjaxUpload)
videoAPI.POST("/:id/thumb", c.VideoAjaxUploadThumb)
videoAPI.POST("/:id/migrate", c.VideoAjaxMigrate)
videoAPI.PUT("/:id/actor/:actor_id", c.VideoAjaxActors)
videoAPI.DELETE("/:id/actor/:actor_id", c.VideoAjaxActors)
videoAPI.POST("/:id/generate-thumbnail/:timing", c.VideoAjaxGenerateThumbnail)
videoAPI.POST("/:id/rename", c.VideoAjaxRename)
videoAPI.POST("/:id/count-view", c.VideoViewAjaxIncrement)
videoAPI.POST("/:id/channel", c.VideoAjaxEditChannel)
// uploads
uploads := admGroup.Group("/upload")
uploads.GET("/", c.UploadTriage)
uploads.GET("/preview/:filepath", c.UploadPreview)
uploads.POST("/import", c.UploadImport)
uploadAPI := admGroup.Group("/api/upload")
uploadAPI.POST("/triage/folder", c.UploadAjaxTriageFolder)
uploadAPI.POST("/triage/file", c.UploadAjaxTriageFile)
uploadAPI.POST("/file", c.UploadAjaxUploadFile)
uploadAPI.DELETE("/file", c.UploadAjaxDeleteFile)
uploadAPI.POST("/folder", c.UploadAjaxFolderCreate)
// adm
admGroup.GET("/adm", c.AdmHome)
admGroup.GET("/adm/videos", c.AdmVideoList)
admGroup.GET("/adm/actors", c.AdmActorList)
admGroup.GET("/adm/channels", c.AdmChannelList)
admGroup.GET("/adm/tasks", c.AdmTaskList)
admGroup.GET("/adm/task/:id", c.AdmTaskView)
admGroup.GET("/adm/users", c.AdmUserList)
admGroup.GET("/adm/user", c.AdmUserNew)
admGroup.POST("/adm/user", c.AdmUserNew)
admGroup.GET("/adm/user/:id/delete", c.AdmUserDelete)
admGroup.POST("/adm/task/:id/retry", c.AdmTaskRetry)
// profile
authGroup.GET("/profile", c.ProfileView)
// errors
authGroup.Any("/error/unauthorized", c.ErrUnauthorized)
// remainings routes to implement
/*
path('actor/<uuid:id>/edit/first-time', views.actor_edit, name='actor_edit_first_time', kwargs={'first_time': True}),
path('actor/<uuid:id>/remove', views.actor_remove, name='actor_remove'),
path('channels', views.ChannelListView.as_view(), name='channel_list'),
path('channel/new', views.channel_new, name='channel_new'),
path('channel/<uuid:id>', views.channel_view, name='channel_view'),
path('channel/<uuid:id>/thumb', views.channel_thumb, name='channel_thumb'),
path('profile', views.profile_view, name='profile_view'),
path('triage/delete/<path:name>', views.triage_delete, name='triage_delete'),
path('uploads', views.upload_home, name='upload_home'),
path('upload/list', views.upload_list, name='upload_list'),
path('upload/<uuid:pk>/stream', views.upload_stream, name='upload_stream'),
path('upload/<uuid:pk>/delete', views.upload_delete, name='upload_delete'),
path('upload/<uuid:pk>/import/<str:import_as>', views.upload_import, name='upload_import'),
path('upload/new', views.upload_new, name='upload_new'),
path('upload/file', views.ChunkedUploadView.as_view(), name='upload_file'),
path('upload/file/<uuid:pk>', views.ChunkedUploadView.as_view(), name='upload_file_view'),
path('adm/actor/fix-thumb', views.adm_actor_fix_missing_thumb, name='adm_actor_fix_missing_thumb'),
path('adm/actor/<uuid:id>/fix-thumb', views.adm_actor_fix_thumb, name='adm_actor_fix_thumb'),
path('adm/actor/<uuid:id>/gen-thumb', views.adm_actor_gen_thumb, name='adm_actor_gen_thumb'),
*/
}
package http
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"time"
)
func (s *Server) Start(bindAddress string) {
s.Server = &http.Server{
Addr: bindAddress,
Handler: s.Router.Handler(),
}
err := s.Server.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
log.Panic(err.Error())
}
}
func (s *Server) WaitForStopSignal(c <-chan int) {
mode := <-c
fmt.Println("http server signal received!", mode)
// shutdown http server
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
_ = s.Server.Shutdown(ctx)
// mode: 1 - shutdown | 2 - restart
switch mode {
case 1:
fmt.Println("server shutdown")
case 2:
log.Println("server restart")
cmd := exec.Command(os.Args[0])
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run()
}
}
package http
import (
"embed"
"io/fs"
"net/http"
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/controller"
)
type Server struct {
Server *http.Server
Router *gin.Engine
FS *embed.FS
}
// main http server setup
func New(c *controller.AbtractController, fs *embed.FS) (*Server, error) {
server := &Server{
Router: gin.Default(),
FS: fs,
}
server.setupRoutes(*c)
// both next settings are needed for filepath used above
server.Router.UseRawPath = true
server.Router.UnescapePathValues = false
server.Router.RemoveExtraSlash = false
return server, nil
}
// failsafe http server setup - no valid config found
func NewFailsafeConfig(c controller.AbtractController, embedfs *embed.FS) (*Server, error) {
server := &Server{
Router: gin.Default(),
FS: embedfs,
}
// load templates
server.LoadHTMLFromEmbedFS("web/page/**/*")
// prepare subfs
staticFS, _ := fs.Sub(server.FS, "web/static")
// load static
server.Router.StaticFS("/static", http.FS(staticFS))
server.Router.GET("/ping", livenessProbe)
// failsafe configuration route
server.Router.GET("", c.FailsafeConfiguration)
server.Router.POST("", c.FailsafeConfiguration)
return server, nil
}
// failsafe http server setup - unexpected error
func NewUnexpectedError(c controller.AbtractController, embedfs *embed.FS, faultyError error) (*Server, error) {
server := &Server{
Router: gin.Default(),
FS: embedfs,
}
// load templates
server.LoadHTMLFromEmbedFS("web/page/**/*")
// prepare subfs
staticFS, _ := fs.Sub(server.FS, "web/static")
// load static
server.Router.StaticFS("/static", http.FS(staticFS))
server.Router.GET("/ping", livenessProbe)
server.Router.GET("", func(g *gin.Context) {
g.HTML(http.StatusOK, "failsafe/error.html", gin.H{
"Error": faultyError,
})
})
return server, nil
}
// failsafe http server setup - no valid config found
func NewFailsafeUser(c controller.AbtractController, embedfs *embed.FS) (*Server, error) {
server := &Server{
Router: gin.Default(),
FS: embedfs,
}
// load templates
server.LoadHTMLFromEmbedFS("web/page/**/*")
// prepare subfs
staticFS, _ := fs.Sub(server.FS, "web/static")
// load static
server.Router.StaticFS("/static", http.FS(staticFS))
server.Router.GET("/ping", livenessProbe)
// failsafe configuration route
server.Router.GET("", c.FailsafeUser)
server.Router.POST("", c.FailsafeUser)
return server, nil
}
package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type ActorAlias struct {
ID string `gorm:"type:uuid;primary_key"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Name string
ActorID string
Actor Actor
}
// UUID pre-hook
func (a *ActorAlias) BeforeCreate(tx *gorm.DB) error {
if a.ID == "00000000-0000-0000-0000-000000000000" {
a.ID = uuid.NewString()
return nil
}
if a.ID == "" {
a.ID = uuid.NewString()
return nil
}
return nil
}
package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type ActorLink struct {
ID string `gorm:"type:uuid;primary_key"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Provider string
ActorID string
Actor Actor
URL string
}
// UUID pre-hook
func (a *ActorLink) BeforeCreate(tx *gorm.DB) error {
if a.ID == "00000000-0000-0000-0000-000000000000" {
a.ID = uuid.NewString()
return nil
}
if a.ID == "" {
a.ID = uuid.NewString()
return nil
}
return nil
}
package model
import (
"fmt"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Actor struct {
ID string `gorm:"type:uuid;primary_key"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Name string
Videos []Video `gorm:"many2many:video_actors;"`
Thumbnail bool
Sex string `gorm:"size:1;"`
Aliases []ActorAlias
Links []ActorLink
}
// UUID pre-hook
func (a *Actor) BeforeCreate(tx *gorm.DB) error {
if a.ID == "00000000-0000-0000-0000-000000000000" {
a.ID = uuid.NewString()
return nil
}
if a.ID == "" {
a.ID = uuid.NewString()
return nil
}
return nil
}
var sexTypesAsString = map[string]string{
"m": "male",
"f": "female",
"s": "shemale",
}
func (a *Actor) SexTypeAsString() string {
return sexTypesAsString[a.Sex]
}
func (a *Actor) AliasesAsNiceString() string {
var aliases []string
for _, alias := range a.Aliases {
aliases = append(aliases, alias.Name)
}
return strings.Join(aliases, " / ")
}
// URLs
func (a *Actor) URLView() string {
return fmt.Sprintf("/actor/%s", a.ID)
}
func (a *Actor) URLThumb() string {
return fmt.Sprintf("/actor/%s/thumb", a.ID)
}
func (a *Actor) URLAdmEdit() string {
return fmt.Sprintf("/actor/%s/edit", a.ID)
}
func (a *Actor) URLAdmDelete() string {
return fmt.Sprintf("/actor/%s/delete", a.ID)
}
package model
import (
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Channel struct {
ID string `gorm:"type:uuid;primary_key"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Name string
Thumbnail bool
}
// UUID pre-hook
func (c *Channel) BeforeCreate(tx *gorm.DB) error {
if c.ID == "00000000-0000-0000-0000-000000000000" {
c.ID = uuid.NewString()
return nil
}
if c.ID == "" {
c.ID = uuid.NewString()
return nil
}
return nil
}
func (c *Channel) URLView() string {
return fmt.Sprintf("/channel/%s", c.ID)
}
func (c *Channel) URLThumb() string {
return fmt.Sprintf("/channel/%s/thumb", c.ID)
}
func (c *Channel) URLAdmEdit() string {
return fmt.Sprintf("/channel/%s/edit", c.ID)
}
package model
import (
"errors"
"github.com/glebarez/sqlite"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/zobtube/zobtube/internal/config"
)
var modelToMigrate = []any{
Actor{},
ActorAlias{},
ActorLink{},
Channel{},
Video{},
VideoView{},
Task{},
User{},
UserSession{},
}
func New(cfg *config.Config) (db *gorm.DB, err error) {
switch cfg.DB.Driver {
case "sqlite":
db, err = gorm.Open(sqlite.Open(cfg.DB.Connstring), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
case "postgresql":
db, err = gorm.Open(postgres.Open(cfg.DB.Connstring), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
default:
return db, errors.New("unsupported driver:" + cfg.DB.Driver)
}
if err != nil {
return nil, err
}
// migrate all known models
for _, m := range modelToMigrate {
err = db.AutoMigrate(&m)
if err != nil {
return nil, err
}
}
return db, nil
}
package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type TaskStatus string
const (
TaskStatusTodo TaskStatus = "todo"
TaskStatusInProgress TaskStatus = "in-progress"
TaskStatusDone TaskStatus = "done"
TaskStatusError TaskStatus = "error"
)
type Task struct {
ID string `gorm:"type:uuid;primary_key"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
DoneAt *time.Time `gorm:"index"`
Name string
Step string
Status TaskStatus `gorm:"default:todo"`
Parameters map[string]string `gorm:"serializer:json"`
}
// UUID pre-hook
func (t *Task) BeforeCreate(tx *gorm.DB) error {
if t.ID == "00000000-0000-0000-0000-000000000000" {
t.ID = uuid.NewString()
return nil
}
if t.ID == "" {
t.ID = uuid.NewString()
return nil
}
return nil
}
package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type UserSession struct {
ID string `gorm:"type:uuid;primary_key"`
CreatedAt time.Time
UpdatedAt time.Time
UserID *string
ValidUntil time.Time
}
// UUID pre-hook
func (u *UserSession) BeforeCreate(tx *gorm.DB) error {
if u.ID == "00000000-0000-0000-0000-000000000000" {
u.ID = uuid.NewString()
return nil
}
if u.ID == "" {
u.ID = uuid.NewString()
return nil
}
return nil
}
package model
import (
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type User struct {
ID string `gorm:"type:uuid;primary_key"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Username string
Password string
Admin bool
Session UserSession
}
// UUID pre-hook
func (u *User) BeforeCreate(tx *gorm.DB) error {
if u.ID == "00000000-0000-0000-0000-000000000000" {
u.ID = uuid.NewString()
return nil
}
if u.ID == "" {
u.ID = uuid.NewString()
return nil
}
return nil
}
func (u *User) URLAdmDelete() string {
return fmt.Sprintf("/adm/user/%s/delete", u.ID)
}
package model
import (
"fmt"
"path/filepath"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type VideoStatus string
const (
VideoStatusCreating VideoStatus = "creating"
VideoStatusReady VideoStatus = "ready"
VideoStatusDeleting VideoStatus = "deleting"
)
// Video model defines the generic video type used for videos, clips and movies
type Video struct {
ID string `gorm:"type:uuid;primary_key"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Name string
Filename string
Actors []Actor `gorm:"many2many:video_actors;"`
Channel *Channel
ChannelID *string
Thumbnail bool
ThumbnailMini bool
Duration time.Duration
Type string `gorm:"size:1;"`
Imported bool `gorm:"default:false"`
Status VideoStatus `gorm:"default:creating"`
}
var videoTypesAsString = map[string]string{
"c": "clip",
"v": "video",
"m": "movie",
}
func (v *Video) BeforeCreate(tx *gorm.DB) error {
if v.ID == "00000000-0000-0000-0000-000000000000" {
v.ID = uuid.NewString()
return nil
}
if v.ID == "" {
v.ID = uuid.NewString()
return nil
}
return nil
}
func (v *Video) TypeAsString() string {
return videoTypesAsString[v.Type]
}
func (v *Video) URLView() string {
return fmt.Sprintf("/video/%s", v.ID)
}
func (v *Video) URLThumb() string {
return fmt.Sprintf("/video/%s/thumb", v.ID)
}
func (v *Video) URLThumbXS() string {
return fmt.Sprintf("/video/%s/thumb_xs", v.ID)
}
func (v *Video) URLStream() string {
return fmt.Sprintf("/video/%s/stream", v.ID)
}
func (v *Video) URLAdmEdit() string {
return fmt.Sprintf("/video/%s/edit", v.ID)
}
func (v *Video) HasDuration() bool {
return true
}
func (v *Video) NiceDuration() string {
d := v.Duration
d = d.Round(time.Second)
h := d / time.Hour
d -= h * time.Hour
m := d / time.Minute
d -= m * time.Minute
s := d / time.Second
if h > 0 {
return fmt.Sprintf("%02d:%02d:%02d", h, m, s)
} else {
return fmt.Sprintf("%02d:%02d", m, s)
}
}
func (v *Video) String() string {
return v.Name
}
var videoFileTypeToPath = map[string]string{
"c": "/clips",
"m": "/movies",
"v": "/videos",
}
func (v *Video) FolderRelativePath() string {
return filepath.Join(videoFileTypeToPath[v.Type], v.ID)
}
func (v *Video) RelativePath() string {
return filepath.Join(v.FolderRelativePath(), "video.mp4")
}
func (v *Video) ThumbnailRelativePath() string {
return filepath.Join(v.FolderRelativePath(), "thumb.jpg")
}
func (v *Video) ThumbnailXSRelativePath() string {
return filepath.Join(v.FolderRelativePath(), "thumb-xs.jpg")
}
package provider
import (
"errors"
"io"
"net/http"
"strings"
"time"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
type Babepedia struct{}
func (p *Babepedia) SlugGet() string {
return "babepedia"
}
func (p *Babepedia) NiceName() string {
return "Babepedia"
}
func (p *Babepedia) ActorSearch(actorName string) (url string, err error) {
client := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
caser := cases.Title(language.English)
url = "https://www.babepedia.com/babe/" + strings.ReplaceAll(caser.String(actorName), " ", "_")
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return url, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT x.y; Win64; x64; rv:10.0) Gecko/20100101 Firefox/10.0")
resp, err := client.Do(req)
if err != nil {
return url, err
}
if resp.StatusCode == 200 {
return url, nil
}
return url, errors.New("provider did not find actor")
}
func (p *Babepedia) ActorGetThumb(actorName, url string) (thumb []byte, err error) {
client := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
caser := cases.Title(language.English)
_url := "https://www.babepedia.com/pics/" + caser.String(actorName) + ".jpg"
req, err := http.NewRequest("GET", _url, nil)
if err != nil {
return thumb, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0")
resp, err := client.Do(req)
if err != nil {
return thumb, err
}
if resp.StatusCode != 200 {
return thumb, err
}
// process thumb
thumbRaw, err := io.ReadAll(resp.Body)
if err != nil {
return thumb, err
}
return thumbRaw, nil
}
package provider
import (
"errors"
"io"
"net/http"
"regexp"
"strings"
"time"
)
type BabesDirectory struct{}
func (p *BabesDirectory) SlugGet() string {
return "babesdirectory"
}
func (p *BabesDirectory) NiceName() string {
return "Babes Directory"
}
func (p *BabesDirectory) ActorSearch(actorName string) (url string, err error) {
client := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
url = "https://babesdirectory.online/profile/" + strings.ReplaceAll(strings.ToLower(actorName), " ", "-")
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return url, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT x.y; Win64; x64; rv:10.0) Gecko/20100101 Firefox/10.0")
resp, err := client.Do(req)
if err != nil {
return url, err
}
if resp.StatusCode == 200 {
return url, nil
}
url = url + "-pornstar"
req, err = http.NewRequest("GET", url, nil)
if err != nil {
return url, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT x.y; Win64; x64; rv:10.0) Gecko/20100101 Firefox/10.0")
resp, err = client.Do(req)
if err != nil {
return url, err
}
if resp.StatusCode == 200 {
return url, nil
}
return url, errors.New("provider did not find actor")
}
func (p *BabesDirectory) ActorGetThumb(actorName, url string) (thumb []byte, err error) {
client := &http.Client{
Timeout: 10 * time.Second,
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return thumb, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT x.y; Win64; x64; rv:10.0) Gecko/20100101 Firefox/10.0")
resp, err := client.Do(req)
if err != nil {
return thumb, err
}
if resp.StatusCode != 200 {
return thumb, errors.New("unable to query provider")
}
// process page
pageData, err := io.ReadAll(resp.Body)
if err != nil {
return thumb, err
}
// try default avatar position of legacy profiles
re := regexp.MustCompile("<div class=\"pill-image\">\\n*\\s*<img src=\"([^\"]*)")
thumbURLMatches := re.FindStringSubmatch(string(pageData))
if len(thumbURLMatches) != 2 || thumbURLMatches[1] == "" {
return thumb, errors.New("provider did not return a thumbnail")
}
// set found url
url = thumbURLMatches[1]
// retrieve thumb
req, err = http.NewRequest("GET", url, nil)
if err != nil {
return thumb, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT x.y; Win64; x64; rv:10.0) Gecko/20100101 Firefox/10.0")
resp, err = client.Do(req)
if err != nil {
return thumb, err
}
if resp.StatusCode != 200 {
return thumb, errors.New("provider thumb retrieval failed")
}
// process thumb
thumbRaw, err := io.ReadAll(resp.Body)
if err != nil {
return thumb, err
}
return thumbRaw, nil
}
package provider
import (
"errors"
"io"
"net/http"
"regexp"
"strings"
"time"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
type Boobpedia struct{}
func (p *Boobpedia) SlugGet() string {
return "boobpedia"
}
func (p *Boobpedia) NiceName() string {
return "Boobpedia"
}
func (p *Boobpedia) ActorSearch(actorName string) (url string, err error) {
client := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
caser := cases.Title(language.English)
url = "https://www.boobpedia.com/boobs/" + strings.ReplaceAll(caser.String(actorName), " ", "_")
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return url, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT x.y; Win64; x64; rv:10.0) Gecko/20100101 Firefox/10.0")
resp, err := client.Do(req)
if err != nil {
return url, err
}
if resp.StatusCode == 200 {
return url, nil
}
return url, errors.New("provider did not find actor")
}
func (p *Boobpedia) ActorGetThumb(actorName, url string) (thumb []byte, err error) {
client := &http.Client{
Timeout: 10 * time.Second,
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return thumb, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT x.y; Win64; x64; rv:10.0) Gecko/20100101 Firefox/10.0")
resp, err := client.Do(req)
if err != nil {
return thumb, err
}
if resp.StatusCode != 200 {
return thumb, errors.New("unable to query provider")
}
// process page
pageData, err := io.ReadAll(resp.Body)
if err != nil {
return thumb, err
}
// try default avatar position of legacy profiles
rLegacy := regexp.MustCompile("<a.*class=\"mw-file-description\">\n*\\s*<img src=\"([^\"]*)")
thumbURLMatches := rLegacy.FindStringSubmatch(string(pageData))
if len(thumbURLMatches) != 2 || thumbURLMatches[1] == "" {
return thumb, errors.New("provider did not return a thumbnail")
}
// set found url
url = thumbURLMatches[1]
//TODO: add secondary search with new profiles
// for reference, regex is: <div class="thumbImage">\n\s*<img src="([^"]*)
// note: regex is most likely multilined
// retrieve thumb
req, err = http.NewRequest("GET", "https://www.boobpedia.com/"+url, nil)
if err != nil {
return thumb, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT x.y; Win64; x64; rv:10.0) Gecko/20100101 Firefox/10.0")
resp, err = client.Do(req)
if err != nil {
return thumb, err
}
if resp.StatusCode != 200 {
return thumb, errors.New("provider thumb retrieval failed")
}
// process thumb
thumbRaw, err := io.ReadAll(resp.Body)
if err != nil {
return thumb, err
}
return thumbRaw, nil
}
package provider
import (
"errors"
"io"
"net/http"
"regexp"
"strings"
"time"
)
type Pornhub struct{}
func (p *Pornhub) SlugGet() string {
return "pornhub"
}
func (p *Pornhub) NiceName() string {
return "PornHub"
}
func (p *Pornhub) ActorSearch(actorName string) (url string, err error) {
client := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
url = "https://www.pornhub.com/pornstar/" + strings.ReplaceAll(strings.ToLower(actorName), " ", "-")
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return url, err
}
req.Header.Add("Cookie", "accessAgeDisclaimerPH=1")
resp, err := client.Do(req)
if err != nil {
return url, err
}
if resp.StatusCode == 200 {
return url, nil
}
url = "https://www.pornhub.com/model/" + strings.ReplaceAll(strings.ToLower(actorName), " ", "-")
req, err = http.NewRequest("GET", url, nil)
if err != nil {
return url, err
}
req.Header.Add("Cookie", "accessAgeDisclaimerPH=1")
resp, err = client.Do(req)
if err != nil {
return url, err
}
if resp.StatusCode == 200 {
return url, nil
}
return url, errors.New("provider did not find actor")
}
func (p *Pornhub) ActorGetThumb(actor_name, url string) (thumb []byte, err error) {
client := &http.Client{
Timeout: 10 * time.Second,
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return thumb, err
}
req.Header.Add("Cookie", "accessAgeDisclaimerPH=1")
resp, err := client.Do(req)
if err != nil {
return thumb, err
}
if resp.StatusCode != 200 {
return thumb, errors.New("unable to query provider")
}
// process page
pageData, err := io.ReadAll(resp.Body)
if err != nil {
return thumb, err
}
// try default avatar position of legacy profiles
rLegacy := regexp.MustCompile("<img id=\"getAvatar\".*src=\"([^\"]*)")
thumbURLMatches := rLegacy.FindStringSubmatch(string(pageData))
if len(thumbURLMatches) != 2 || thumbURLMatches[1] == "" {
// second attempt on new page format
rNew := regexp.MustCompile("<div class=\"thumbImage\">\\n\\s*<img src=\"([^\"]*)")
thumbURLMatches = rNew.FindStringSubmatch(string(pageData))
// check result
if len(thumbURLMatches) != 2 || thumbURLMatches[1] == "" {
// definitely not found
return thumb, errors.New("provider did not return a thumbnail")
}
}
// set found url
url = thumbURLMatches[1]
// retrieve thumb
req, err = http.NewRequest("GET", url, nil)
if err != nil {
return thumb, err
}
req.Header.Add("Cookie", "accessAgeDisclaimerPH=1")
resp, err = client.Do(req)
if err != nil {
return thumb, err
}
if resp.StatusCode != 200 {
return thumb, errors.New("provider thumb retrieval failed")
}
// process thumb
thumbRaw, err := io.ReadAll(resp.Body)
if err != nil {
return thumb, err
}
return thumbRaw, nil
}
package runner
import (
"gorm.io/gorm"
"github.com/zobtube/zobtube/internal/config"
"github.com/zobtube/zobtube/internal/model"
"github.com/zobtube/zobtube/internal/task/common"
)
type RunnerEvent int
const (
NewTaskEvent RunnerEvent = 1
)
type Runner struct {
tasks []*common.Task
tasksChannel map[string]chan RunnerEvent
ctx *common.Context
}
func (r *Runner) RegisterTask(t *common.Task) {
r.tasks = append(r.tasks, t)
if r.tasksChannel == nil {
r.tasksChannel = make(map[string]chan RunnerEvent)
}
r.tasksChannel[t.Name] = make(chan RunnerEvent)
}
func (r *Runner) Start(cfg *config.Config, db *gorm.DB) {
r.ctx = &common.Context{
DB: db,
Config: cfg,
}
for _, task := range r.tasks {
go func() {
for {
event := <-r.tasksChannel[task.Name]
if event == NewTaskEvent {
task.Run(r.ctx)
}
}
}()
}
}
func (r *Runner) NewTask(action string, params map[string]string) error {
task := &model.Task{
Name: action,
Parameters: params,
}
err := r.ctx.DB.Create(&task).Error
if err != nil {
return err
}
r.tasksChannel[action] <- NewTaskEvent
return nil
}
func (r *Runner) TaskRetry(action string) {
r.tasksChannel[action] <- NewTaskEvent
}
package common
import (
"log"
"time"
"github.com/zobtube/zobtube/internal/config"
"github.com/zobtube/zobtube/internal/model"
"gorm.io/gorm"
)
type Parameters map[string]string
type Context struct {
DB *gorm.DB
Config *config.Config
}
type Step struct {
Name string
NiceName string
Func func(*Context, Parameters) (string, error)
}
type Task struct {
Name string
Steps []Step
}
func (t *Task) Run(ctx *Context) {
// select all task matching object + action
log.Println("start runner for task: " + t.Name)
var tasks []model.Task
ctx.DB.Where("name = ? and status = ?", t.Name, "todo").Find(&tasks)
for _, task := range tasks {
t.runTask(ctx, &task)
}
}
func (t *Task) runTask(ctx *Context, task *model.Task) {
// set first step if first run
if task.Step == "" {
task.Step = t.Steps[0].Name
ctx.DB.Save(task)
}
log.SetPrefix(t.Name + "/" + task.ID + " ")
log.Println("running on step: ", task.Step)
stepNB := findStepNB(t.Steps, task.Step)
errMsg, err := t.Steps[stepNB].Func(ctx, task.Parameters)
if err != nil {
log.Println("task failed:", errMsg, err.Error())
task.Status = model.TaskStatusError
ctx.DB.Save(&task)
return
}
stepNB++
if stepNB == len(t.Steps) {
task.Status = model.TaskStatusDone
now := time.Now()
task.DoneAt = &now
ctx.DB.Save(&task)
log.Println("task done")
return
}
task.Step = t.Steps[stepNB].Name
ctx.DB.Save(&task)
t.runTask(ctx, task)
}
func findStepNB(steps []Step, step string) int {
for stepNB := range steps {
if steps[stepNB].Name == step {
return stepNB
}
}
return -1
}
package video
import (
"errors"
"github.com/zobtube/zobtube/internal/model"
"github.com/zobtube/zobtube/internal/task/common"
)
func NewVideoCreating() *common.Task {
task := &common.Task{
Name: "video/create",
Steps: []common.Step{
{
Name: "triage-import",
NiceName: "Move the video from triage to its own folder",
Func: importFromTriage,
},
{
Name: "compute-duration",
NiceName: "Compute duration of the video",
Func: computeDuration,
},
{
Name: "generate-thumbnail",
NiceName: "Generate thumbnail",
Func: generateThumbnail,
},
{
Name: "generate-thumbnail-mini",
NiceName: "Generate mini thumbnail",
Func: generateThumbnailMini,
},
{
Name: "creating-to-ready",
NiceName: "Finalize DB status",
Func: creatingToReady,
},
},
}
return task
}
func creatingToReady(ctx *common.Context, params common.Parameters) (string, error) {
videoID := params["videoID"]
// get item from ID
video := &model.Video{
ID: videoID,
}
result := ctx.DB.First(video)
// check result
if result.RowsAffected < 1 {
return "video does not exist", errors.New("id not in db")
}
video.Status = model.VideoStatusReady
err := ctx.DB.Save(&video).Error
if err != nil {
return "unable to save video readiness", err
}
return "", nil
}
package video
import (
"errors"
"os"
"path/filepath"
"github.com/zobtube/zobtube/internal/model"
"github.com/zobtube/zobtube/internal/task/common"
)
func NewVideoDeleting() *common.Task {
task := &common.Task{
Name: "video/delete",
Steps: []common.Step{
{
Name: "delete-thumbnail",
NiceName: "Delete video's thumbnail",
Func: deleteThumbnail,
},
{
Name: "delete-thumbnail-mini",
NiceName: "Delete video's mini thumbnail",
Func: deleteThumbnailMini,
},
{
Name: "delete-video",
NiceName: "Delete video",
Func: deleteVideoFile,
},
{
Name: "delete-video-folder",
NiceName: "Delete video's folder",
Func: deleteFolder,
},
{
Name: "delete-video-in-db",
NiceName: "Delete video's database entry",
Func: deleteInDatabase,
},
},
}
return task
}
func deleteVideoFile(ctx *common.Context, params common.Parameters) (string, error) {
videoID := params["videoID"]
// get item from ID
video := &model.Video{
ID: videoID,
}
result := ctx.DB.First(video)
// check result
if result.RowsAffected < 1 {
return "video does not exist", errors.New("id not in db")
}
// check video presence
videoPath := filepath.Join(ctx.Config.Media.Path, video.RelativePath())
_, err := os.Stat(videoPath)
if err != nil && !os.IsNotExist(err) {
return "unable to check video presence on disk", err
}
if !os.IsNotExist(err) {
// exist, deleting it
err = os.Remove(videoPath)
if err != nil {
return "unable to delete video on disk", err
}
}
return "", nil
}
func deleteFolder(ctx *common.Context, params common.Parameters) (string, error) {
videoID := params["videoID"]
// get item from ID
video := &model.Video{
ID: videoID,
}
result := ctx.DB.First(video)
// check result
if result.RowsAffected < 1 {
return "video does not exist", errors.New("id not in db")
}
// delete folder
folderPath := filepath.Join(ctx.Config.Media.Path, video.FolderRelativePath())
_, err := os.Stat(folderPath)
if err != nil && !os.IsNotExist(err) {
return "unable to check video folder presence", err
}
if !os.IsNotExist(err) {
// exist, deleting it
err = os.Remove(folderPath)
if err != nil {
return "unable to delete video folder", err
}
}
return "", nil
}
func deleteInDatabase(ctx *common.Context, params common.Parameters) (string, error) {
videoID := params["videoID"]
// get item from ID
video := &model.Video{
ID: videoID,
}
result := ctx.DB.First(video)
// check result
if result.RowsAffected < 1 {
return "video does not exist", errors.New("id not in db")
}
err := ctx.DB.Delete(&video).Error
if err != nil {
return "unable to delete video", err
}
return "", nil
}
package video
import (
"errors"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/zobtube/zobtube/internal/model"
"github.com/zobtube/zobtube/internal/task/common"
)
func computeDuration(ctx *common.Context, params common.Parameters) (string, error) {
videoID := params["videoID"]
// get item from ID
video := &model.Video{
ID: videoID,
}
result := ctx.DB.First(video)
// check result
if result.RowsAffected < 1 {
return "video does not exist", errors.New("id not in db")
}
filePath := filepath.Join(ctx.Config.Media.Path, video.RelativePath())
out, err := exec.Command(
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
filePath,
).Output()
if err != nil {
return "unable to retrieve video length", err
}
duration := strings.TrimSpace(string(out))
d, err := time.ParseDuration(duration + "s")
if err != nil {
return "unable to parse duration", err
}
video.Duration = d
err = ctx.DB.Save(&video).Error
if err != nil {
return "unable to save video duration", err
}
return "", nil
}
package video
import "github.com/zobtube/zobtube/internal/task/common"
func NewVideoGenerateThumbnail() *common.Task {
task := &common.Task{
Name: "video/generate-thumbnail",
Steps: []common.Step{
{
Name: "generate-thumbnail",
NiceName: "Generate thumbnail",
Func: generateThumbnail,
},
{
Name: "generate-thumbnail-mini",
NiceName: "Generate mini thumbnail",
Func: generateThumbnailMini,
},
},
}
return task
}
package video
import (
"image"
"image/color"
"math"
)
// gaussianBlur applies a gaussian kernel filter on src and return a new blurred image.
func gaussianBlur(src *image.RGBA, ksize float64) *image.RGBA {
// kernel of gaussian 15x15
ks := int(ksize)
k := make([]float64, ks*ks)
for i := 0; i < ks; i++ {
for j := 0; j < ks; j++ {
//nolint:staticcheck // QF1005 expanding Pow will make it even more unreadable
k[i*ks+j] = math.Exp(-(math.Pow(float64(i)-ksize/2, 2)+math.Pow(float64(j)-ksize/2, 2))/(2*math.Pow(ksize/2, 2))) / 256
}
}
// make an image that is ksize larger than the original
dst := image.NewRGBA(src.Bounds())
// apply
for y := src.Bounds().Min.Y; y < src.Bounds().Max.Y; y++ {
for x := src.Bounds().Min.X; x < src.Bounds().Max.X; x++ {
var r, g, b, a float64
for ky := 0; ky < ks; ky++ {
for kx := 0; kx < ks; kx++ {
// get the source pixel
c := src.At(x+kx-ks/2, y+ky-ks/2)
r1, g1, b1, a1 := c.RGBA()
// get the kernel value
k := k[ky*ks+kx]
// accumulate
r += float64(r1) * k
g += float64(g1) * k
b += float64(b1) * k
a += float64(a1) * k
}
}
// set the destination pixel
dst.Set(x, y, color.RGBA{uint8(r / 273), uint8(g / 273), uint8(b / 273), uint8(a / 273)})
}
}
return dst
}
package video
import (
"errors"
"image"
"image/jpeg"
"os"
"path/filepath"
"github.com/zobtube/zobtube/internal/model"
"github.com/zobtube/zobtube/internal/task/common"
"golang.org/x/image/draw"
)
func generateThumbnailMini(ctx *common.Context, params common.Parameters) (string, error) {
videoID := params["videoID"]
// get item from ID
video := &model.Video{
ID: videoID,
}
result := ctx.DB.First(video)
// check result
if result.RowsAffected < 1 {
return "video does not exist", errors.New("id not in db")
}
// construct paths
thumbPath := filepath.Join(ctx.Config.Media.Path, video.ThumbnailRelativePath())
thumbPath, err := filepath.Abs(thumbPath)
if err != nil {
return "Unable to get absolute path of the thumbnail", err
}
thumbXSPath := filepath.Join(ctx.Config.Media.Path, video.ThumbnailXSRelativePath())
thumbXSPath, err = filepath.Abs(thumbXSPath)
if err != nil {
return "Unable to get absolute path of the new mini thumbnail", err
}
// open files
input, _ := os.Open(thumbPath)
defer input.Close()
output, _ := os.Create(thumbXSPath)
defer output.Close()
// decode the image from jpeg to image.Image
src, err := jpeg.Decode(input)
if err != nil {
return "unable to read the jpg file", err
}
targetH := 320
targetV := 180
h := src.Bounds().Dx()
v := src.Bounds().Dy()
originalImageRGBA := image.NewRGBA(image.Rect(0, 0, h, v))
draw.Draw(originalImageRGBA, originalImageRGBA.Bounds(), src, src.Bounds().Min, draw.Src)
ratioH := float32(h) / float32(targetH)
ratioV := float32(v) / float32(targetV)
ratio := max(ratioH, ratioV)
h = int(float32(h) / ratio)
v = int(float32(v) / ratio)
// set new size
dst := image.NewRGBA(image.Rect(0, 0, targetH, targetV))
// draw outer
outerImg := gaussianBlur(originalImageRGBA, 15)
draw.NearestNeighbor.Scale(dst, dst.Bounds(), outerImg, outerImg.Bounds(), draw.Over, nil)
// draw inner
innerH := (targetH - h) / 2
innerV := (targetV - v) / 2
draw.NearestNeighbor.Scale(dst, image.Rect(innerH, innerV, innerH+h, innerV+v), src, src.Bounds(), draw.Over, nil)
// encode to jpeg
err = jpeg.Encode(output, dst, &jpeg.Options{Quality: 90})
if err != nil {
return "unable to encode new thumbnail", err
}
// save on db
video.ThumbnailMini = true
ctx.DB.Save(&video)
// ret
return "", nil
}
func deleteThumbnailMini(ctx *common.Context, params common.Parameters) (string, error) {
videoID := params["videoID"]
// get item from ID
video := &model.Video{
ID: videoID,
}
result := ctx.DB.First(video)
// check result
if result.RowsAffected < 1 {
return "video does not exist", errors.New("id not in db")
}
// check thumb-xs presence
thumbXsPath := filepath.Join(ctx.Config.Media.Path, video.ThumbnailXSRelativePath())
_, err := os.Stat(thumbXsPath)
if err != nil && !os.IsNotExist(err) {
return "unable to check mini thumbnail presence", err
}
if !os.IsNotExist(err) {
// exist, deleting it
err = os.Remove(thumbXsPath)
if err != nil {
return "unable to delete mini thumbnail", err
}
}
return "", nil
}
package video
import (
"errors"
"os"
"os/exec"
"path/filepath"
"github.com/zobtube/zobtube/internal/model"
"github.com/zobtube/zobtube/internal/task/common"
)
func generateThumbnail(ctx *common.Context, params common.Parameters) (string, error) {
// get id from path
id := params["videoID"]
// get timing from path
timing := params["thumbnailTiming"]
// get item from ID
video := &model.Video{
ID: id,
}
result := ctx.DB.First(video)
// check result
if result.RowsAffected < 1 {
return "video does not exist", errors.New("id not in db")
}
// construct paths
videoPath := filepath.Join(ctx.Config.Media.Path, video.RelativePath())
videoPath, err := filepath.Abs(videoPath)
if err != nil {
return "Unable to get absolute path of video", err
}
thumbPath := filepath.Join(ctx.Config.Media.Path, video.ThumbnailRelativePath())
thumbPath, err = filepath.Abs(thumbPath)
if err != nil {
return "Unable to get absolute path of the new thumbnail", err
}
_, err = exec.Command(
"ffmpeg",
"-y",
"-ss",
timing,
"-i",
videoPath,
"-frames:v",
"1",
"-q:v",
"2",
thumbPath,
).Output()
if err != nil {
return "Unable to generate thumbnail with ffmpeg", err
}
video.Thumbnail = true
ctx.DB.Save(&video)
return "", nil
}
func deleteThumbnail(ctx *common.Context, params common.Parameters) (string, error) {
videoID := params["videoID"]
// get item from ID
video := &model.Video{
ID: videoID,
}
result := ctx.DB.First(video)
// check result
if result.RowsAffected < 1 {
return "video does not exist", errors.New("id not in db")
}
// check thumb presence
thumbPath := filepath.Join(ctx.Config.Media.Path, video.ThumbnailRelativePath())
_, err := os.Stat(thumbPath)
if err != nil && !os.IsNotExist(err) {
return "unable to check thumbnail presence", err
}
if !os.IsNotExist(err) {
// exist, deleting it
err = os.Remove(thumbPath)
if err != nil {
return "unable to delete thumbnail", err
}
}
return "", nil
}
package video
import (
"errors"
"os"
"path/filepath"
"github.com/zobtube/zobtube/internal/model"
"github.com/zobtube/zobtube/internal/task/common"
)
func importFromTriage(ctx *common.Context, params common.Parameters) (string, error) {
// get id from path
id := params["videoID"]
// get item from ID
video := &model.Video{
ID: id,
}
result := ctx.DB.First(video)
// check result
if result.RowsAffected < 1 {
return "video does not exist", errors.New("id not in db")
}
// prepare paths
previousPath := filepath.Join(ctx.Config.Media.Path, "/triage", video.Filename)
newFolderPath := filepath.Join(ctx.Config.Media.Path, video.FolderRelativePath())
newPath := filepath.Join(ctx.Config.Media.Path, video.RelativePath())
// ensure folder exists
_, err := os.Stat(newFolderPath)
if os.IsNotExist(err) {
// do not exists, create it
err = os.Mkdir(newFolderPath, os.ModePerm)
if err != nil {
return "unable to create new video folder", err
}
} else if err != nil {
return "unable to read new video folder", err
}
// move
err = os.Rename(previousPath, newPath)
if err != nil {
return "unable to move new video into its folder", err
}
// commit the update on database
video.Imported = true
err = ctx.DB.Save(video).Error
if err != nil {
return "unable to update database", err
}
return "", nil
}
package main
import (
"embed"
"errors"
"fmt"
"sync"
"github.com/zobtube/zobtube/internal/config"
"github.com/zobtube/zobtube/internal/controller"
"github.com/zobtube/zobtube/internal/http"
"github.com/zobtube/zobtube/internal/model"
"github.com/zobtube/zobtube/internal/provider"
"github.com/zobtube/zobtube/internal/runner"
"github.com/zobtube/zobtube/internal/task/video"
)
//go:embed web
var webFS embed.FS
// const
const cfgPath = "config.yml"
// errors
var ErrNoUser = errors.New("database does not have any account")
// channel for http server shutdown
var wg sync.WaitGroup
var shutdownChannel chan int
// goreleaser build-time variables
var (
version = "dev"
commit = "none"
date = "unknown"
)
func startFailsafeWebServer(err error, c controller.AbtractController) {
// http server
httpServer := &http.Server{}
switch err {
case config.ErrNoDbDriverSet, config.ErrNoDbConnStringSet, config.ErrNoMediaPathSet:
httpServer, _ = http.NewFailsafeConfig(c, &webFS)
case ErrNoUser:
httpServer, _ = http.NewFailsafeUser(c, &webFS)
default:
httpServer, _ = http.NewUnexpectedError(c, &webFS, err)
}
// handle shutdown
go httpServer.WaitForStopSignal(shutdownChannel)
httpServer.Start("0.0.0.0:8080")
// Wait for all HTTP fetches to complete.
wg.Wait()
fmt.Println("exiting")
}
func main() {
wg.Add(1)
// http server
httpServer := &http.Server{}
// channel for http server shutdown
shutdownChannel = make(chan int)
// create controller
c := controller.New(shutdownChannel)
cfg, err := config.New(cfgPath)
if err != nil {
startFailsafeWebServer(err, c)
return
}
err = cfg.EnsureTreePresent()
if err != nil {
startFailsafeWebServer(err, c)
return
}
c.ConfigurationRegister(cfg)
db, err := model.New(cfg)
if err != nil {
startFailsafeWebServer(err, c)
return
}
c.DatabaseRegister(db)
// check if at least one user exists
var count int64
db.Model(&model.User{}).Count(&count)
if count == 0 {
startFailsafeWebServer(ErrNoUser, c)
return
}
c.ProviderRegister(&provider.BabesDirectory{})
c.ProviderRegister(&provider.Babepedia{})
c.ProviderRegister(&provider.Boobpedia{})
c.ProviderRegister(&provider.Pornhub{})
go c.CleanupRoutine()
runner := &runner.Runner{}
runner.RegisterTask(video.NewVideoCreating())
runner.RegisterTask(video.NewVideoDeleting())
runner.RegisterTask(video.NewVideoGenerateThumbnail())
runner.Start(cfg, db)
c.RunnerRegister(runner)
c.BuildDetailsRegister(version, commit, date)
// create http server
httpServer, _ = http.New(&c, &webFS)
// serve content
httpServer.Start(cfg.Server.Bind)
}