package passwordreset
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"github.com/rs/zerolog"
"github.com/urfave/cli/v3"
"github.com/zobtube/zobtube/internal/config"
"github.com/zobtube/zobtube/internal/model"
)
const userIdFromCmd = "user-id"
type Parameters struct{}
func Run(cmd *cli.Command, logger *zerolog.Logger) error {
// setup configuration
logger.Log().Msg("setting up configuration")
cfg, err := config.New(
logger,
cmd.String("server-bind"),
cmd.String("db-driver"),
cmd.String("db-connstring"),
cmd.String("media-path"),
)
if err != nil {
return err
}
// init database
logger.Log().Msg("initializing database connection")
db, err := model.New(cfg)
if err != nil {
return err
}
// get user id from prompt
userID := cmd.String(userIdFromCmd)
// if user id is empty, display the full list of user with their ids
if userID == "" {
logger.Log().Msg("get user list")
users := []model.User{}
result := db.Find(&users)
if result.RowsAffected < 1 {
logger.Warn().Msg("no user found. Check your database configuration")
return nil
}
for _, user := range users {
logger.Log().Msg(fmt.Sprintf("* ID: %s (username: %s)", user.ID, user.Username))
}
logger.Log().Msg("please now use the --user-id flag to select the user")
return nil
}
logger.Log().Str(userIdFromCmd, userID).Msg("get selected user")
user := &model.User{
ID: userID,
}
result := db.First(user)
if result.RowsAffected < 1 {
logger.Log().Msg("user not found, check the user id")
return nil
}
newPassword := generatePassword()
logger.Log().Str(userIdFromCmd, userID).Msg(fmt.Sprintf("new password for user %s will be %s", user.Username, newPassword))
passwordHex := sha256.Sum256([]byte(newPassword))
password := hex.EncodeToString(passwordHex[:])
user.Password = password
err = db.Save(&user).Error
if result.RowsAffected < 1 {
logger.Error().Err(err).Msg("unable to save new password")
return err
}
logger.Info().Str(userIdFromCmd, userID).Msg("new password set successfully")
return nil
}
func generatePassword() string {
return rand.Text()
}
package server
import (
"github.com/zobtube/zobtube/internal/controller"
"github.com/zobtube/zobtube/internal/http"
)
func startFailsafeWebServer(httpServer *http.Server, err error, c controller.AbstractController) {
httpServer.Logger.Warn().
Str("mode", "failsafe").
Str("reason", "error during boot").
Err(err).
Msg("start zobtube in failsafe mode")
httpServer.ControllerSetupFailsafeError(c, err)
// handle shutdown
go httpServer.WaitForStopSignal(shutdownChannel)
_err := httpServer.Start("0.0.0.0:8069")
if _err != nil {
httpServer.Logger.Error().Err(err).Msg("unable to start failsafe server")
return
}
// Wait for all HTTP fetches to complete.
wg.Wait()
httpServer.Logger.Warn().Msg("zobtube exiting failsafe webserver")
}
package server
import (
"fmt"
"os/exec"
"github.com/zobtube/zobtube/internal/controller"
)
func dependencyRegister(c controller.AbstractController, params *Parameters, dep string) {
// external providers
path, err := exec.LookPath(dep)
if err != nil {
params.Logger.Warn().Str("kind", "dependency").Err(err).Msg("unable to check dependency")
c.RegisterError(fmt.Sprintf("Unable to ensure presence of %s with error: %s", dep, err.Error()))
}
params.Logger.Debug().
Str("kind", "dependency").
Str("dependency", dep).
Msg(fmt.Sprintf("available at %s", path))
}
package server
import (
"fmt"
"github.com/zobtube/zobtube/internal/controller"
"github.com/zobtube/zobtube/internal/provider"
)
func providerRegister(c controller.AbstractController, params *Parameters, p provider.Provider) {
err := c.ProviderRegister(p)
if err != nil {
params.Logger.Warn().Str("kind", "provider").Err(err).Msg("unable to register provider")
c.RegisterError(fmt.Sprintf("Unable to register provider %s with error: %s", p.NiceName(), err.Error()))
}
}
package server
import (
"context"
"embed"
"sync"
"github.com/rs/zerolog"
"github.com/urfave/cli/v3"
"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/storage"
"github.com/zobtube/zobtube/internal/task/video"
)
type Parameters struct {
Ctx context.Context
Cmd *cli.Command
Logger *zerolog.Logger
Version string
Commit string
Date string
WebFS *embed.FS
}
// channel for http server shutdown
var (
wg sync.WaitGroup
shutdownChannel chan int
)
func Start(params *Parameters) error {
// setup log level
// #nosec G115
zerolog.SetGlobalLevel(zerolog.Level(params.Cmd.Int("log-level")))
// initialize logger
params.Logger.Info().Msg("zobtube starting")
// create http server
httpServer := http.New(params.WebFS, params.Cmd.Bool("gin-debug"), params.Logger)
wg.Add(1)
// channel for http server shutdown
shutdownChannel = make(chan int)
// handle shutdown
go httpServer.WaitForStopSignal(shutdownChannel)
// create controller
c := controller.New(shutdownChannel)
// register logger
c.LoggerRegister(params.Logger)
// setup configuration
cfg, err := config.New(
params.Logger,
params.Cmd.String("server-bind"),
params.Cmd.String("db-driver"),
params.Cmd.String("db-connstring"),
params.Cmd.String("media-path"),
)
if err != nil {
startFailsafeWebServer(httpServer, err, c)
return nil
}
params.Logger.Debug().Str("kind", "system").Msg("ensure library folders are present")
c.ConfigurationRegister(cfg)
params.Logger.Debug().Str("kind", "system").Msg("apply models on database")
db, err := model.New(cfg)
if err != nil {
startFailsafeWebServer(httpServer, err, c)
return nil
}
c.DatabaseRegister(db)
params.Logger.Debug().Str("kind", "system").Msg("ensure default library")
defaultLibID, err := model.EnsureDefaultLibrary(db, cfg.Media.Path)
if err != nil {
startFailsafeWebServer(httpServer, err, c)
return nil
}
params.Logger.Debug().Str("kind", "system").Msg("backfill video library_id")
if err := model.BackfillVideoLibraryID(db, defaultLibID); err != nil {
startFailsafeWebServer(httpServer, err, c)
return nil
}
cfg.DefaultLibraryID = defaultLibID
params.Logger.Debug().Str("kind", "system").Msg("ensure library folders for filesystem libraries")
var libs []model.Library
if err := db.Where("type = ?", model.LibraryTypeFilesystem).Find(&libs).Error; err != nil {
startFailsafeWebServer(httpServer, err, c)
return nil
}
for _, lib := range libs {
if lib.Config.Filesystem != nil && lib.Config.Filesystem.Path != "" {
if err := config.EnsureTreePresentForPath(lib.Config.Filesystem.Path); err != nil {
startFailsafeWebServer(httpServer, err, c)
return nil
}
}
}
storageResolver := storage.NewResolver(db)
c.StorageResolverRegister(storageResolver)
params.Logger.Debug().Str("kind", "system").Msg("check if at least one user exists")
var count int64
db.Model(&model.User{}).Count(&count)
if count == 0 {
// instance first start, create a default user
params.Logger.Warn().Str("kind", "system").Msg("no user setup, creating default admin")
newUser := &model.User{
Username: "admin",
Admin: true,
}
// save it
tx := db.Begin()
err = tx.Save(&newUser).Error
if err != nil {
params.Logger.Error().Str("kind", "system").Err(err).Msg("unable to create initial user")
tx.Rollback()
startFailsafeWebServer(httpServer, err, c)
return err
}
// register the instance to be authentication-less
config := &model.Configuration{
ID: 1,
UserAuthentication: false,
}
// save it
err = tx.Assign(&config).FirstOrCreate(&config).Error
if err != nil {
params.Logger.Error().Str("kind", "system").Err(err).Msg("unable to create initial user")
tx.Rollback()
startFailsafeWebServer(httpServer, err, c)
return err
}
tx.Commit()
} else {
// at least one user present
// now checking if configuration is set (allowing migration from previous versions)
config := &model.Configuration{}
result := db.First(config)
// check result
if result.RowsAffected < 1 {
params.Logger.Warn().
Str("kind", "system").
Msg("configuration unset with existing users, enabling authentication")
// register the instance to be authentication-less
config := &model.Configuration{
ID: 1,
UserAuthentication: true,
}
// save it
err = db.Assign(&config).FirstOrCreate(&config).Error
if err != nil {
params.Logger.Error().Str("kind", "system").Err(err).Msg("unable to create initial configuration")
startFailsafeWebServer(httpServer, err, c)
return err
}
}
}
// loading configuration from database
dbconfig := &model.Configuration{}
result := db.First(dbconfig)
// check result
if result.RowsAffected < 1 {
params.Logger.Fatal().Str("kind", "system").Msg("configuration should not be empty")
return nil
}
c.ConfigurationFromDBApply(dbconfig)
// external providers
params.Logger.Debug().Str("kind", "system").Msg("register external providers")
providers := []provider.Provider{
&provider.BabesDirectory{},
&provider.Babepedia{},
&provider.Boobpedia{},
&provider.Pornhub{},
&provider.IAFD{},
}
for _, provider := range providers {
providerRegister(c, params, provider)
}
// check dependencies
params.Logger.Debug().Str("kind", "system").Msg("check dependencies")
dependencies := []string{
"ffmpeg",
"ffprobe",
}
for _, dep := range dependencies {
dependencyRegister(c, params, dep)
}
go c.CleanupRoutine()
runner := &runner.Runner{}
runner.RegisterTask(video.NewVideoCreating())
runner.RegisterTask(video.NewVideoDeleting())
runner.RegisterTask(video.NewVideoMoveLibrary())
runner.RegisterTask(video.NewVideoGenerateThumbnail())
runner.Start(cfg, db, storageResolver)
c.RunnerRegister(runner)
c.BuildDetailsRegister(params.Version, params.Commit, params.Date)
// register controller
httpServer.ControllerSetupDefault(&c)
// start http server
return httpServer.Start(cfg.Server.Bind)
}
package config
import (
"os"
"path/filepath"
)
var defaultLibraryFolders = []string{
"clips",
"movies",
"videos",
"actors",
"triage",
}
// EnsureTreePresent ensures the library folder and default subfolders exist at path.
// Used for the single configured media path (backward compat) or for each filesystem library path.
func (cfg *Config) EnsureTreePresent() error {
return EnsureTreePresentForPath(cfg.Media.Path)
}
// EnsureTreePresentForPath ensures the library folder and default subfolders exist at the given path.
func EnsureTreePresentForPath(path string) error {
_, err := os.Stat(path)
if os.IsNotExist(err) {
err = os.Mkdir(path, 0o750)
if err != nil {
return err
}
} else if err != nil {
return err
}
for _, folder := range defaultLibraryFolders {
dir := filepath.Join(path, folder)
_, err := os.Stat(dir)
if os.IsNotExist(err) {
err = os.Mkdir(dir, 0o750)
if err != nil {
return err
}
} else if err != nil {
return err
}
}
return nil
}
package config
import (
"errors"
"github.com/rs/zerolog"
)
type Config struct {
Server struct {
Bind string
}
DB struct {
Driver string
Connstring string
}
Media struct {
Path string
}
// DefaultLibraryID is set after bootstrap; used for actor/channel/category assets and default upload target.
DefaultLibraryID string
Authentication bool
}
func New(logger *zerolog.Logger, serverBind, dbDriver, dbConnstring, mediaPath string) (*Config, error) {
cfg := &Config{}
cfg.Server.Bind = serverBind
cfg.DB.Driver = dbDriver
cfg.DB.Connstring = dbConnstring
cfg.Media.Path = mediaPath
// pre flight checks
if cfg.DB.Driver == "" {
return cfg, errors.New("ZT_DB_DRIVER is not set")
}
if cfg.DB.Connstring == "" {
return cfg, errors.New("ZT_DB_CONNSTRING is not set")
}
if cfg.Media.Path == "" {
return cfg, errors.New("ZT_MEDIA_PATH is not set")
}
logger.Info().
Str("db-driver", cfg.DB.Driver).
Str("server-bind", cfg.Server.Bind).
Str("media-path", cfg.Media.Path).
Msg("valid configuration found")
return cfg, nil
}
package controller
import (
"io"
"net/http"
"path/filepath"
"github.com/gin-gonic/gin"
"gorm.io/gorm/clause"
"github.com/zobtube/zobtube/internal/model"
)
const errHumanProviderNotFound = "Unable to retrieve provider"
// ActorList godoc
//
// @Summary List all actors
// @Tags actor
// @Produce json
// @Success 200 {object} map[string]model.Actor[]
// @Router /actor [get]
func (c *Controller) ActorList(g *gin.Context) {
var actors []model.Actor
c.datastore.Preload("Videos").Preload("Links").Order("name").Find(&actors)
g.JSON(http.StatusOK, gin.H{
"items": actors,
"total": len(actors),
})
}
// ActorGet godoc
//
// @Summary Get actor by ID
// @Tags actor
// @Produce json
// @Param id path string true "Actor ID"
// @Success 200 {object} model.Actor
// @Failure 404
// @Router /actor/{id} [get]
func (c *Controller) ActorGet(g *gin.Context) {
id := g.Param("id")
actor := &model.Actor{ID: id}
result := c.datastore.Preload(clause.Associations).First(actor)
if result.RowsAffected < 1 {
g.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
g.JSON(http.StatusOK, actor)
}
// ActorDelete godoc
//
// @Summary Delete an actor
// @Tags actor
// @Param id path string true "Actor ID"
// @Success 204
// @Failure 404
// @Router /actor/{id} [delete]
func (c *Controller) ActorDelete(g *gin.Context) {
id := g.Param("id")
actor := &model.Actor{ID: id}
result := c.datastore.First(actor)
if result.RowsAffected < 1 {
g.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
store, err := c.storageResolver.Storage(c.config.DefaultLibraryID)
if err != nil {
g.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
thumbPath := filepath.Join("actors", actor.ID, "thumb.jpg")
_ = store.Delete(thumbPath)
if err := c.datastore.Delete(actor).Error; err != nil {
g.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
g.JSON(http.StatusNoContent, gin.H{})
}
// ActorThumb godoc
//
// @Summary Get actor thumbnail image
// @Tags actor
// @Param id path string true "Actor ID"
// @Success 200 file bytes
// @Failure 404
// @Router /actor/{id}/thumb [get]
func (c *Controller) ActorThumb(g *gin.Context) {
id := g.Param("id")
actor := &model.Actor{ID: id}
result := c.datastore.First(actor)
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
if !actor.Thumbnail {
g.Redirect(http.StatusFound, ACTOR_PROFILE_PICTURE_MISSING)
return
}
store, err := c.storageResolver.Storage(c.config.DefaultLibraryID)
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
path := filepath.Join("actors", id, "thumb.jpg")
c.serveFromStorage(g, store, path)
}
// ActorNew godoc
//
// @Summary Create a new actor
// @Tags actor
// @Accept multipart/form-data
// @Param id formData string true "Actor ID"
// @Param name formData string true "Actor name"
// @Param sex formData string false "Sex (m/f)"
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /actor [post]
func (c *Controller) ActorNew(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,
})
}
// ActorProviderSearch godoc
//
// @Summary Search actor in external provider and create link
// @Tags actor
// @Param id path string true "Actor ID"
// @Param provider_slug path string true "Provider slug (babesdirectory, babepedia, etc.)"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Router /actor/{id}/provider/{provider_slug} [get]
func (c *Controller) ActorProviderSearch(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": errHumanProviderNotFound,
})
return
}
// loading configuration from database
dbconfig := &model.Configuration{}
result = c.datastore.First(dbconfig)
// check result
if result.RowsAffected < 1 {
g.JSON(500, gin.H{
"error": "configuration not found, restarting the appliaction should fix the issue",
})
return
}
url, err := provider.ActorSearch(dbconfig.OfflineMode, 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,
})
}
// ActorLinkThumbGet godoc
//
// @Summary Get thumbnail from actor link (provider)
// @Tags actor
// @Param id path string true "Link ID"
// @Success 200 file bytes
// @Failure 404 {object} map[string]interface{}
// @Router /actor/link/{id}/thumb [get]
func (c *Controller) ActorLinkThumbGet(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": errHumanProviderNotFound,
})
return
}
// loading configuration from database
dbconfig := &model.Configuration{}
result = c.datastore.First(dbconfig)
// check result
if result.RowsAffected < 1 {
g.JSON(500, gin.H{
"error": "configuration not found, restarting the appliaction should fix the issue",
})
return
}
thumb, err := provider.ActorGetThumb(dbconfig.OfflineMode, 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)
}
// ActorLinkThumbDelete godoc
//
// @Summary Delete actor link
// @Tags actor
// @Param id path string true "Link ID"
// @Success 200
// @Failure 404 {object} map[string]interface{}
// @Router /actor/link/{id} [delete]
func (c *Controller) ActorLinkThumbDelete(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{})
}
// ActorUploadThumb godoc
//
// @Summary Upload actor thumbnail
// @Tags actor
// @Accept multipart/form-data
// @Param id path string true "Actor ID"
// @Param pp formData file true "Thumbnail image"
// @Success 200
// @Failure 404 {object} map[string]interface{}
// @Router /actor/{id}/thumb [post]
func (c *Controller) ActorUploadThumb(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
}
store, err := c.storageResolver.Storage(c.config.DefaultLibraryID)
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
path := filepath.Join("actors", id, "thumb.jpg")
if err := store.MkdirAll(filepath.Dir(path)); err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
src, err := file.Open()
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
defer src.Close()
dst, err := store.Create(path)
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
defer dst.Close()
if _, err := io.Copy(dst, src); 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{})
}
// ActorLinkCreate godoc
//
// @Summary Create actor link to provider profile
// @Tags actor
// @Accept x-www-form-urlencoded
// @Param id path string true "Actor ID"
// @Param url formData string true "Provider profile URL"
// @Param provider formData string true "Provider slug"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Router /actor/{id}/link [post]
func (c *Controller) ActorLinkCreate(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": errHumanProviderNotFound,
})
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,
})
}
// ActorAliasCreate godoc
//
// @Summary Add alias to actor
// @Tags actor
// @Accept x-www-form-urlencoded
// @Param id path string true "Actor ID"
// @Param alias formData string true "Alias name"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /actor/{id}/alias [post]
func (c *Controller) ActorAliasCreate(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,
})
}
// ActorAliasRemove godoc
//
// @Summary Remove actor alias
// @Tags actor
// @Param id path string true "Alias ID"
// @Success 200
// @Failure 404 {object} map[string]interface{}
// @Router /actor/alias/{id} [delete]
func (c *Controller) ActorAliasRemove(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{})
}
// ActorCategories godoc
//
// @Summary Add or remove category from actor (PUT=add, DELETE=remove)
// @Tags actor
// @Param id path string true "Actor ID"
// @Param category_id path string true "Category (sub) ID"
// @Success 200
// @Failure 404 {object} map[string]interface{}
// @Router /actor/{id}/category/{category_id} [put]
// @Router /actor/{id}/category/{category_id} [delete]
func (c *Controller) ActorCategories(g *gin.Context) {
// get id from path
id := g.Param("id")
category_id := g.Param("category_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{
"error": "actor not found",
})
return
}
subCategory := &model.CategorySub{
ID: category_id,
}
result = c.datastore.First(&subCategory)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{
"error": "sub-category not found",
})
return
}
var res error
if g.Request.Method == "PUT" {
res = c.datastore.Model(actor).Association("Categories").Append(subCategory)
} else {
res = c.datastore.Model(actor).Association("Categories").Delete(subCategory)
}
if res != nil {
g.JSON(500, gin.H{
"error": res.Error(),
})
return
}
g.JSON(200, gin.H{})
}
// ActorRename godoc
//
// @Summary Rename actor
// @Tags actor
// @Accept x-www-form-urlencoded
// @Param id path string true "Actor ID"
// @Param name formData string true "New name"
// @Success 200
// @Failure 400 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Router /actor/{id}/rename [post]
func (c *Controller) ActorRename(g *gin.Context) {
var err error
form := struct {
Name string `form:"name"`
}{}
err = g.ShouldBind(&form)
if err != nil {
g.JSON(400, gin.H{
"error": err.Error(),
})
return
}
if form.Name == "" {
g.JSON(400, gin.H{
"error": "actor name cannot be empty",
})
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
}
actor.Name = form.Name
err = c.datastore.Save(actor).Error
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
g.JSON(200, gin.H{})
}
// ActorDescription godoc
//
// @Summary Set actor description
// @Tags actor
// @Accept x-www-form-urlencoded
// @Param id path string true "Actor ID"
// @Param description formData string false "Description text"
// @Success 200
// @Failure 400 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Router /actor/{id}/description [post]
func (c *Controller) ActorDescription(g *gin.Context) {
var err error
form := struct {
Description string `form:"description"`
}{}
err = g.ShouldBind(&form)
if err != nil {
g.JSON(400, 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
}
actor.Description = form.Description
err = c.datastore.Save(actor).Error
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
g.JSON(200, gin.H{})
}
// ActorMerge godoc
//
// @Summary Merge source actor into target actor
// @Tags actor
// @Accept json
// @Param id path string true "Source actor ID"
// @Param body body object true "JSON with target_id"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Router /actor/{id}/merge [post]
func (c *Controller) ActorMerge(g *gin.Context) {
sourceID := g.Param("id")
form := struct {
TargetID string `json:"target_id"`
}{}
if err := g.ShouldBindJSON(&form); err != nil {
g.JSON(400, gin.H{"error": err.Error()})
return
}
targetID := form.TargetID
if targetID == "" {
g.JSON(400, gin.H{"error": "target_id is required"})
return
}
if sourceID == targetID {
g.JSON(400, gin.H{"error": "source and target must be different"})
return
}
source := &model.Actor{ID: sourceID}
if res := c.datastore.Preload("Videos").Preload("Aliases").Preload("Links").Preload("Categories").First(source); res.RowsAffected < 1 {
g.JSON(404, gin.H{"error": "source actor not found"})
return
}
target := &model.Actor{ID: targetID}
if res := c.datastore.Preload("Aliases").Preload("Links").First(target); res.RowsAffected < 1 {
g.JSON(404, gin.H{"error": "target actor not found"})
return
}
tx := c.datastore.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
for i := range source.Videos {
video := &source.Videos[i]
if err := tx.Model(video).Association("Actors").Delete(source); err != nil {
tx.Rollback()
g.JSON(500, gin.H{"error": err.Error()})
return
}
if err := tx.Model(video).Association("Actors").Append(target); err != nil {
tx.Rollback()
g.JSON(500, gin.H{"error": err.Error()})
return
}
}
targetAliasNames := make(map[string]struct{})
for _, a := range target.Aliases {
targetAliasNames[a.Name] = struct{}{}
}
for i := range source.Aliases {
a := &source.Aliases[i]
if _, exists := targetAliasNames[a.Name]; exists {
if err := tx.Delete(a).Error; err != nil {
tx.Rollback()
g.JSON(500, gin.H{"error": err.Error()})
return
}
} else {
a.ActorID = target.ID
if err := tx.Save(a).Error; err != nil {
tx.Rollback()
g.JSON(500, gin.H{"error": err.Error()})
return
}
targetAliasNames[a.Name] = struct{}{}
}
}
type linkKey struct{ Provider, URL string }
targetLinks := make(map[linkKey]struct{})
for _, l := range target.Links {
targetLinks[linkKey{l.Provider, l.URL}] = struct{}{}
}
for i := range source.Links {
l := &source.Links[i]
k := linkKey{l.Provider, l.URL}
if _, exists := targetLinks[k]; exists {
if err := tx.Delete(l).Error; err != nil {
tx.Rollback()
g.JSON(500, gin.H{"error": err.Error()})
return
}
} else {
l.ActorID = target.ID
if err := tx.Save(l).Error; err != nil {
tx.Rollback()
g.JSON(500, gin.H{"error": err.Error()})
return
}
targetLinks[k] = struct{}{}
}
}
for i := range source.Categories {
cat := &source.Categories[i]
if err := tx.Model(target).Association("Categories").Append(cat); err != nil {
tx.Rollback()
g.JSON(500, gin.H{"error": err.Error()})
return
}
}
if err := tx.Model(source).Association("Categories").Clear(); err != nil {
tx.Rollback()
g.JSON(500, gin.H{"error": err.Error()})
return
}
if err := tx.Delete(source).Error; err != nil {
tx.Rollback()
g.JSON(500, gin.H{"error": err.Error()})
return
}
if err := tx.Commit().Error; err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
store, err := c.storageResolver.Storage(c.config.DefaultLibraryID)
if err == nil {
thumbPath := filepath.Join("actors", source.ID, "thumb.jpg")
_ = store.Delete(thumbPath)
}
g.JSON(200, gin.H{"redirect": "/actor/" + target.ID + "/edit"})
}
package controller
import (
"crypto/sha256"
"encoding/hex"
"net/http"
"os"
"runtime"
"time"
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/model"
)
// AdmHome godoc
//
// @Summary Admin dashboard overview
// @Tags admin
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /adm [get]
func (c *Controller) AdmHome(g *gin.Context) {
var videoCount, actorCount, channelCount, userCount, categoryCount int64
c.datastore.Table("videos").Where(NOT_DELETED).Count(&videoCount)
c.datastore.Table("actors").Where(NOT_DELETED).Count(&actorCount)
c.datastore.Table("channels").Where(NOT_DELETED).Count(&channelCount)
c.datastore.Table("users").Where(NOT_DELETED).Count(&userCount)
c.datastore.Table("categories").Where(NOT_DELETED).Count(&categoryCount)
binaryPath, _ := os.Executable()
workingDirectory, _ := os.Getwd()
g.JSON(http.StatusOK, gin.H{
"build": c.build,
"video_count": videoCount,
"actor_count": actorCount,
"channel_count": channelCount,
"user_count": userCount,
"category_count": categoryCount,
"golang_version": runtime.Version(),
"db_driver": c.config.DB.Driver,
"binary_path": binaryPath,
"startup_directory": workingDirectory,
"health_errors": c.healthError,
})
}
// AdmVideoList godoc
//
// @Summary List all videos (admin)
// @Tags admin
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /adm/video [get]
func (c *Controller) AdmVideoList(g *gin.Context) {
var videos []model.Video
c.datastore.Find(&videos)
g.JSON(http.StatusOK, gin.H{"items": videos, "total": len(videos)})
}
// AdmActorList godoc
//
// @Summary List all actors (admin)
// @Tags admin
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /adm/actor [get]
func (c *Controller) AdmActorList(g *gin.Context) {
var actors []model.Actor
c.datastore.Find(&actors)
g.JSON(http.StatusOK, gin.H{"items": actors, "total": len(actors)})
}
// AdmChannelList godoc
//
// @Summary List all channels (admin)
// @Tags admin
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /adm/channel [get]
func (c *Controller) AdmChannelList(g *gin.Context) {
var channels []model.Channel
c.datastore.Find(&channels)
g.JSON(http.StatusOK, gin.H{"items": channels, "total": len(channels)})
}
// AdmCategory godoc
//
// @Summary List all categories with sub (admin)
// @Tags admin
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /adm/category [get]
func (c *Controller) AdmCategory(g *gin.Context) {
var categories []model.Category
result := c.datastore.Preload("Sub").Find(&categories)
if result.RowsAffected < 1 {
g.JSON(http.StatusOK, gin.H{"items": []model.Category{}, "total": 0})
return
}
g.JSON(http.StatusOK, gin.H{"items": categories, "total": len(categories)})
}
// AdmTaskList godoc
//
// @Summary List all tasks
// @Tags admin
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /adm/task [get]
func (c *Controller) AdmTaskList(g *gin.Context) {
var tasks []model.Task
c.datastore.Find(&tasks)
g.JSON(http.StatusOK, gin.H{"items": tasks, "total": len(tasks)})
}
// AdmTaskHome godoc
//
// @Summary Get recent tasks for admin dashboard
// @Tags admin
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /adm/task/home [get]
func (c *Controller) AdmTaskHome(g *gin.Context) {
var tasks []model.Task
c.datastore.Limit(5).Order("created_at DESC").Find(&tasks)
g.JSON(http.StatusOK, gin.H{"items": tasks, "total": len(tasks)})
}
// AdmTaskView godoc
//
// @Summary Get task by ID
// @Tags admin
// @Produce json
// @Param id path string true "Task ID"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Router /adm/task/{id} [get]
func (c *Controller) AdmTaskView(g *gin.Context) {
id := g.Param("id")
task := &model.Task{ID: id}
if result := c.datastore.First(task); result.RowsAffected < 1 {
g.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
g.JSON(http.StatusOK, task)
}
// AdmTaskRetry godoc
//
// @Summary Retry failed task
// @Tags admin
// @Param id path string true "Task ID"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Router /adm/task/{id}/retry [post]
func (c *Controller) AdmTaskRetry(g *gin.Context) {
id := g.Param("id")
task := &model.Task{ID: id}
if result := c.datastore.First(task); result.RowsAffected < 1 {
g.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
task.Status = model.TaskStatusTodo
if err := c.datastore.Save(task).Error; err != nil {
g.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.runner.TaskRetry(task.Name)
g.JSON(http.StatusOK, gin.H{"redirect": "/adm/task/" + task.ID})
}
// AdmUserList godoc
//
// @Summary List all users
// @Tags admin
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /adm/user [get]
func (c *Controller) AdmUserList(g *gin.Context) {
var users []model.User
c.datastore.Find(&users)
g.JSON(http.StatusOK, gin.H{"items": users, "total": len(users)})
}
// AdmUserNew godoc
//
// @Summary Create new user
// @Tags admin
// @Accept json
// @Param body body object true "JSON with username, password, admin"
// @Success 201 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 409 {object} map[string]interface{}
// @Router /adm/user [post]
func (c *Controller) AdmUserNew(g *gin.Context) {
var body struct {
Username string `json:"username"`
Password string `json:"password"`
Admin bool `json:"admin"`
}
if err := g.ShouldBindJSON(&body); err != nil {
g.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if body.Password == "" {
g.JSON(http.StatusBadRequest, gin.H{"error": "password cannot be empty"})
return
}
var existing model.User
if result := c.datastore.First(&existing, "username = ?", body.Username); result.RowsAffected > 0 {
g.JSON(http.StatusConflict, gin.H{"error": "username already taken"})
return
}
passwordHex := sha256.Sum256([]byte(body.Password))
newUser := &model.User{
Username: body.Username,
Admin: body.Admin,
Password: hex.EncodeToString(passwordHex[:]),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := c.datastore.Create(newUser).Error; err != nil {
g.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
g.JSON(http.StatusCreated, gin.H{"id": newUser.ID, "redirect": "/adm/users"})
}
// AdmUserDelete godoc
//
// @Summary Delete user
// @Tags admin
// @Param id path string true "User ID"
// @Success 204
// @Failure 404 {object} map[string]interface{}
// @Router /adm/user/{id} [delete]
func (c *Controller) AdmUserDelete(g *gin.Context) {
id := g.Param("id")
user := model.User{ID: id}
if result := c.datastore.First(&user); result.RowsAffected < 1 {
g.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if err := c.datastore.Delete(&user).Error; err != nil {
g.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
g.JSON(http.StatusNoContent, gin.H{})
}
// AdmConfigAuth godoc
//
// @Summary Get auth configuration
// @Tags admin
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /adm/config/auth [get]
func (c *Controller) AdmConfigAuth(g *gin.Context) {
dbconfig := &model.Configuration{}
if result := c.datastore.First(dbconfig); result.RowsAffected < 1 {
g.JSON(http.StatusInternalServerError, gin.H{"error": ErrConfigAbsent})
return
}
g.JSON(http.StatusOK, gin.H{"authentication_enabled": dbconfig.UserAuthentication})
}
// AdmConfigAuthUpdate godoc
//
// @Summary Enable or disable authentication
// @Tags admin
// @Param action path string true "enable or disable"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /adm/config/auth/{action} [get]
func (c *Controller) AdmConfigAuthUpdate(g *gin.Context) {
action := g.Param("action")
if action != "enable" && action != "disable" {
g.JSON(http.StatusBadRequest, gin.H{"error": "invalid action"})
return
}
dbconfig := &model.Configuration{}
if result := c.datastore.First(dbconfig); result.RowsAffected < 1 {
g.JSON(http.StatusInternalServerError, gin.H{"error": ErrConfigAbsent})
return
}
dbconfig.UserAuthentication = action == "enable"
if err := c.datastore.Save(dbconfig).Error; err != nil {
g.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.ConfigurationFromDBApply(dbconfig)
g.JSON(http.StatusOK, gin.H{"redirect": "/adm/config/auth"})
}
// AdmConfigProvider godoc
//
// @Summary Get provider configuration
// @Tags admin
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /adm/config/provider [get]
func (c *Controller) AdmConfigProvider(g *gin.Context) {
var providers []model.Provider
c.datastore.Find(&providers)
dbconfig := &model.Configuration{}
if result := c.datastore.First(dbconfig); result.RowsAffected < 1 {
g.JSON(http.StatusInternalServerError, gin.H{"error": ErrConfigAbsent})
return
}
providerLoaded := make(map[string]string)
for k, p := range c.providers {
providerLoaded[k] = p.NiceName()
}
g.JSON(http.StatusOK, gin.H{
"providers": providers,
"provider_loaded": providerLoaded,
"offline_mode": dbconfig.OfflineMode,
})
}
// AdmConfigProviderSwitch godoc
//
// @Summary Toggle provider enabled/disabled
// @Tags admin
// @Param id path string true "Provider ID"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Router /adm/config/provider/{id}/switch [get]
func (c *Controller) AdmConfigProviderSwitch(g *gin.Context) {
providerID := g.Param("id")
provider := model.Provider{ID: providerID}
if result := c.datastore.First(&provider); result.RowsAffected < 1 {
g.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
return
}
provider.Enabled = !provider.Enabled
if err := c.datastore.Save(&provider).Error; err != nil {
g.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
g.JSON(http.StatusOK, gin.H{"redirect": "/adm/config/provider"})
}
// AdmConfigOfflineMode godoc
//
// @Summary Get offline mode configuration
// @Tags admin
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /adm/config/offline [get]
func (c *Controller) AdmConfigOfflineMode(g *gin.Context) {
dbconfig := &model.Configuration{}
if result := c.datastore.First(dbconfig); result.RowsAffected < 1 {
g.JSON(http.StatusInternalServerError, gin.H{"error": ErrConfigAbsent})
return
}
g.JSON(http.StatusOK, gin.H{"offline_mode": dbconfig.OfflineMode})
}
// AdmConfigOfflineModeUpdate godoc
//
// @Summary Enable or disable offline mode
// @Tags admin
// @Param action path string true "enable or disable"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /adm/config/offline/{action} [get]
func (c *Controller) AdmConfigOfflineModeUpdate(g *gin.Context) {
action := g.Param("action")
if action != "enable" && action != "disable" {
g.JSON(http.StatusBadRequest, gin.H{"error": "invalid action"})
return
}
dbconfig := &model.Configuration{}
if result := c.datastore.First(dbconfig); result.RowsAffected < 1 {
g.JSON(http.StatusInternalServerError, gin.H{"error": ErrConfigAbsent})
return
}
dbconfig.OfflineMode = action == "enable"
if err := c.datastore.Save(dbconfig).Error; err != nil {
g.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.ConfigurationFromDBApply(dbconfig)
g.JSON(http.StatusOK, gin.H{"redirect": "/adm/config/offline"})
}
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/zobtube/zobtube/internal/model"
)
// AdmLibraryList godoc
//
// @Summary List all libraries (admin)
// @Tags admin
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /adm/libraries [get]
func (c *Controller) AdmLibraryList(g *gin.Context) {
var libs []model.Library
c.datastore.Order("created_at").Find(&libs)
g.JSON(http.StatusOK, gin.H{"items": libs, "total": len(libs)})
}
// AdmLibraryCreate godoc
//
// @Summary Create a library (admin)
// @Tags admin
// @Accept json
// @Param body body object true "JSON with name, type, config"
// @Success 201 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /adm/libraries [post]
func (c *Controller) AdmLibraryCreate(g *gin.Context) {
var body struct {
Name string `json:"name" binding:"required"`
Type model.LibraryType `json:"type" binding:"required"`
Config model.LibraryConfig `json:"config"`
Default bool `json:"default"`
}
if err := g.ShouldBindJSON(&body); err != nil {
g.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
lib := model.Library{
ID: uuid.NewString(),
Name: body.Name,
Type: body.Type,
Config: body.Config,
IsDefault: body.Default,
}
if body.Type == model.LibraryTypeFilesystem && body.Config.Filesystem != nil && body.Config.Filesystem.Path != "" {
// no-op
} else if body.Type == model.LibraryTypeS3 && body.Config.S3 != nil && body.Config.S3.Bucket != "" {
// no-op
} else {
g.JSON(http.StatusBadRequest, gin.H{"error": "invalid config for library type"})
return
}
if err := c.datastore.Create(&lib).Error; err != nil {
g.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if body.Default {
c.datastore.Model(&model.Library{}).Where("id != ?", lib.ID).Update("is_default", false)
}
if c.storageResolver != nil {
c.storageResolver.Invalidate(lib.ID)
}
g.JSON(http.StatusCreated, gin.H{"id": lib.ID, "library": lib})
}
// AdmLibraryUpdate godoc
//
// @Summary Update a library (admin)
// @Tags admin
// @Accept json
// @Param id path string true "Library ID"
// @Param body body object true "JSON with name, config, default"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /adm/libraries/{id} [put]
func (c *Controller) AdmLibraryUpdate(g *gin.Context) {
id := g.Param("id")
var lib model.Library
if err := c.datastore.First(&lib, "id = ?", id).Error; err != nil {
g.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
var body struct {
Name *string `json:"name"`
Type *model.LibraryType `json:"type"`
Config *model.LibraryConfig `json:"config"`
Default *bool `json:"default"`
}
if err := g.ShouldBindJSON(&body); err != nil {
g.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if body.Name != nil {
lib.Name = *body.Name
}
if body.Type != nil {
t := *body.Type
if t != model.LibraryTypeFilesystem && t != model.LibraryTypeS3 {
g.JSON(http.StatusBadRequest, gin.H{"error": "invalid library type"})
return
}
lib.Type = t
}
if body.Config != nil {
newConfig := *body.Config
// Preserve S3 secret when edit form omits it (empty = keep existing)
if newConfig.S3 != nil && lib.Config.S3 != nil && newConfig.S3.SecretAccessKey == "" && lib.Config.S3.SecretAccessKey != "" {
newConfig.S3.SecretAccessKey = lib.Config.S3.SecretAccessKey
}
lib.Config = newConfig
}
// Validate config matches type
if lib.Type == model.LibraryTypeFilesystem && (lib.Config.Filesystem == nil || lib.Config.Filesystem.Path == "") {
g.JSON(http.StatusBadRequest, gin.H{"error": "filesystem library requires config.filesystem.path"})
return
}
if lib.Type == model.LibraryTypeS3 && (lib.Config.S3 == nil || lib.Config.S3.Bucket == "") {
g.JSON(http.StatusBadRequest, gin.H{"error": "s3 library requires config.s3 with bucket"})
return
}
if body.Default != nil {
lib.IsDefault = *body.Default
if lib.IsDefault {
c.datastore.Model(&model.Library{}).Where("id != ?", lib.ID).Update("is_default", false)
}
}
if err := c.datastore.Save(&lib).Error; err != nil {
g.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if c.storageResolver != nil {
c.storageResolver.Invalidate(lib.ID)
}
g.JSON(http.StatusOK, gin.H{"library": lib})
}
// AdmLibraryDelete godoc
//
// @Summary Delete a library (admin)
// @Tags admin
// @Param id path string true "Library ID"
// @Success 204
// @Failure 400 {object} map[string]interface{}
// @Router /adm/libraries/{id} [delete]
func (c *Controller) AdmLibraryDelete(g *gin.Context) {
id := g.Param("id")
var lib model.Library
if err := c.datastore.First(&lib, "id = ?", id).Error; err != nil {
g.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if lib.IsDefault {
g.JSON(http.StatusBadRequest, gin.H{"error": "cannot delete the default library"})
return
}
var videoCount int64
c.datastore.Model(&model.Video{}).Where("library_id = ?", id).Count(&videoCount)
if videoCount > 0 {
g.JSON(http.StatusBadRequest, gin.H{"error": "library has videos, move or delete them first"})
return
}
if err := c.datastore.Delete(&lib).Error; err != nil {
g.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if c.storageResolver != nil {
c.storageResolver.Invalidate(id)
}
g.Status(http.StatusNoContent)
}
package controller
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/model"
)
// Bootstrap returns auth_enabled and current user (or nil) without requiring authentication.
//
// Bootstrap godoc
//
// @Summary Get bootstrap data (auth status, current user)
// @Tags bootstrap
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /bootstrap [get]
func (c *Controller) Bootstrap(g *gin.Context) {
user := &model.User{}
if c.config.Authentication {
if _, err := g.Cookie(cookieName); err != nil {
c.createSession(g)
} else {
cookie, _ := g.Cookie(cookieName)
session := &model.UserSession{ID: cookie}
if c.datastore.First(session).RowsAffected > 0 &&
session.ValidUntil.After(time.Now()) &&
session.UserID != nil && *session.UserID != "" {
u := &model.User{ID: *session.UserID}
if c.datastore.First(u).RowsAffected > 0 {
user = u
}
}
}
} else {
_ = c.datastore.Order("created_at").First(user)
}
resp := gin.H{"auth_enabled": c.config.Authentication}
if user.ID != "" {
resp["user"] = gin.H{"id": user.ID, "username": user.Username, "admin": user.Admin}
} else {
resp["user"] = nil
}
g.JSON(http.StatusOK, resp)
}
func (c *Controller) SPAApp(g *gin.Context) {
// Ensure session cookie exists for bootstrap
if c.config.Authentication {
if _, err := g.Cookie(cookieName); err != nil {
c.createSession(g)
}
}
g.HTML(http.StatusOK, "web/page/app-static.html", gin.H{})
}
// NoRouteOrSPA serves SPA for GET requests to non-API, non-static paths; otherwise returns JSON 404.
func (c *Controller) NoRouteOrSPA(g *gin.Context) {
if g.Request.Method == http.MethodGet {
path := g.Request.URL.Path
if !strings.HasPrefix(path, "/api") && !strings.HasPrefix(path, "/static") && path != "/ping" {
c.SPAApp(g)
return
}
}
c.ErrNotFound(g)
}
package controller
import (
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/zobtube/zobtube/internal/model"
)
const (
cookieName = "zt_auth"
cookieSecure = false
cookieHttpOnly = false
sessionTimePending = 10 * time.Minute
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, "/", "", 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)
}
func (c *Controller) GetFirstUser(user *model.User) *gorm.DB {
return c.datastore.Order("created_at").First(user)
}
func (c *Controller) AuthenticationEnabled() bool {
return c.config.Authentication
}
package controller
import (
"crypto/sha256"
"encoding/hex"
"net/http"
"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 {
c.createSession(g)
g.JSON(401, gin.H{
"error": "no session cookie",
})
return
}
// 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, "/", "", cookieSecure, cookieHttpOnly)
g.JSON(200, gin.H{})
}
// AuthMe godoc
//
// @Summary Get current authenticated user
// @Tags auth
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /auth/me [get]
func (c *Controller) AuthMe(g *gin.Context) {
user := g.MustGet("user").(*model.User)
g.JSON(200, gin.H{
"id": user.ID,
"username": user.Username,
"admin": user.Admin,
})
}
// AuthLogout godoc
//
// @Summary Log out current user
// @Tags auth
// @Produce json
// @Success 204
// @Router /auth/logout [post]
func (c *Controller) AuthLogout(g *gin.Context) {
cookie, err := g.Cookie(cookieName)
if err != nil {
g.JSON(http.StatusNoContent, gin.H{})
return
}
session := &model.UserSession{ID: cookie}
result := c.datastore.First(session)
if result.RowsAffected > 0 {
c.datastore.Delete(&session)
}
g.SetCookie(cookieName, "", -1, "/", "", cookieSecure, cookieHttpOnly)
g.JSON(http.StatusNoContent, gin.H{})
}
// AuthLogoutRedirect handles GET /auth/logout: clears session and redirects to home.
func (c *Controller) AuthLogoutRedirect(g *gin.Context) {
cookie, _ := g.Cookie(cookieName)
if cookie != "" {
session := &model.UserSession{ID: cookie}
if c.datastore.First(session).RowsAffected > 0 {
c.datastore.Delete(session)
}
g.SetCookie(cookieName, "", -1, "/", "", cookieSecure, cookieHttpOnly)
}
g.Redirect(http.StatusFound, "/")
}
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/model"
)
// CategoryList godoc
//
// @Summary List all categories
// @Tags category
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /category [get]
func (c *Controller) CategoryList(g *gin.Context) {
var categories []model.Category
c.datastore.Preload("Sub").Find(&categories)
g.JSON(http.StatusOK, gin.H{
"items": categories,
"total": len(categories),
})
}
// CategorySubGet godoc
//
// @Summary Get category sub with videos and actors
// @Tags category
// @Produce json
// @Param id path string true "Category sub ID"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Router /category/{id} [get]
func (c *Controller) CategorySubGet(g *gin.Context) {
id := g.Param("id")
sub := &model.CategorySub{ID: id}
result := c.datastore.Preload("Videos").Preload("Actors.Videos").First(sub)
if result.RowsAffected < 1 {
g.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
g.JSON(http.StatusOK, sub)
}
// CategoryAdd godoc
//
// @Summary Create a new category
// @Tags category
// @Accept x-www-form-urlencoded
// @Param Name formData string true "Category name"
// @Param Type formData string false "Category type"
// @Param Scope formData string false "Category scope"
// @Success 200
// @Failure 400 {object} map[string]interface{}
// @Router /category [post]
func (c *Controller) CategoryAdd(g *gin.Context) {
var err error
// check method
if g.Request.Method != "POST" {
g.JSON(405, gin.H{})
return
}
// check form
type CategoryForm struct {
Name string
Type string
Scope string
}
var form CategoryForm
err = g.ShouldBind(&form)
if err != nil {
g.JSON(400, gin.H{
"error": err,
})
return
}
// check emptiness
if form.Name == "" {
g.JSON(400, gin.H{
"error": "category name cannot be empty",
})
return
}
// create object
category := &model.Category{
Name: form.Name,
}
err = c.datastore.Create(&category).Error
if err != nil {
g.JSON(500, gin.H{
"error": err,
})
return
}
g.JSON(200, gin.H{})
}
// CategoryDelete godoc
//
// @Summary Delete a category
// @Tags category
// @Param id path string true "Category ID"
// @Success 200
// @Failure 400 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Router /category/{id} [delete]
func (c *Controller) CategoryDelete(g *gin.Context) {
// get category id from path
id := g.Param("id")
category := &model.Category{
ID: id,
}
result := c.datastore.Preload("Sub").First(category)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
if len(category.Sub) > 0 {
g.JSON(400, gin.H{
"error": "category cannot be deleted with values presents",
})
return
}
err := c.datastore.Delete(&category).Error
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
"human_error": "unable to delete category",
})
return
}
g.JSON(200, gin.H{})
}
package controller
import (
"fmt"
"io"
"net/http"
"path/filepath"
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/model"
)
// CategorySubAdd godoc
//
// @Summary Create a new category sub
// @Tags category
// @Accept x-www-form-urlencoded
// @Param Name formData string true "Sub-category name"
// @Param Parent formData string true "Parent category ID"
// @Success 200
// @Failure 500 {object} map[string]interface{}
// @Router /category-sub [post]
func (c *Controller) CategorySubAdd(g *gin.Context) {
var err error
if g.Request.Method == "POST" {
type CategorySubForm struct {
Name string
Parent string
}
var form CategorySubForm
err = g.ShouldBind(&form)
if err == nil {
category := &model.CategorySub{
Name: form.Name,
Category: form.Parent,
}
err = c.datastore.Create(&category).Error
if err == nil {
g.JSON(200, gin.H{})
return
}
}
}
g.JSON(500, gin.H{
"error": err,
})
}
// CategorySubThumbSet godoc
//
// @Summary Set category sub thumbnail
// @Tags category
// @Accept multipart/form-data
// @Param id path string true "Category sub ID"
// @Param pp formData file true "Thumbnail image"
// @Success 200
// @Failure 404 {object} map[string]interface{}
// @Router /category-sub/{id}/thumb [post]
func (c *Controller) CategorySubThumbSet(g *gin.Context) {
// get item from ID
category := &model.CategorySub{
ID: g.Param("id"),
}
result := c.datastore.First(category)
// 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(), "human_error": "unable to retrieve thumbnail from form"})
return
}
store, err := c.storageResolver.Storage(c.config.DefaultLibraryID)
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
filename := fmt.Sprintf("%s.jpg", category.ID)
path := filepath.Join("categories", filename)
src, err := file.Open()
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
defer src.Close()
dst, err := store.Create(path)
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
// check if thumbnail exists
if !category.Thumbnail {
category.Thumbnail = true
err = c.datastore.Save(category).Error
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
}
// all good
g.JSON(200, gin.H{})
}
// CategorySubThumbRemove godoc
//
// @Summary Remove category sub thumbnail
// @Tags category
// @Param id path string true "Category sub ID"
// @Success 200
// @Failure 404 {object} map[string]interface{}
// @Router /category-sub/{id}/thumb [delete]
func (c *Controller) CategorySubThumbRemove(g *gin.Context) {
// get item from ID
category := &model.CategorySub{
ID: g.Param("id"),
}
result := c.datastore.First(category)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
// construct file path
filename := fmt.Sprintf("%s.jpg", category.ID)
path := filepath.Join("categories", filename)
store, err := c.storageResolver.Storage(c.config.DefaultLibraryID)
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
_ = store.Delete(path)
// check if thumbnail exists
if category.Thumbnail {
category.Thumbnail = false
err = c.datastore.Save(category).Error
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
}
// all good
g.JSON(200, gin.H{})
}
// CategorySubRename godoc
//
// @Summary Rename category sub
// @Tags category
// @Accept x-www-form-urlencoded
// @Param id path string true "Category sub ID"
// @Param title formData string true "New name"
// @Success 200
// @Failure 404 {object} map[string]interface{}
// @Router /category-sub/{id}/rename [post]
func (c *Controller) CategorySubRename(g *gin.Context) {
// get id from path
id := g.Param("id")
// get new name
var form struct {
Title string `form:"title"`
}
err := g.ShouldBind(&form)
if err != nil {
// method not allowed
g.JSON(406, gin.H{})
return
}
// get item from ID
category := &model.CategorySub{
ID: id,
}
result := c.datastore.First(category)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
category.Name = form.Title
err = c.datastore.Save(category).Error
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
// all good
g.JSON(200, gin.H{})
}
// CategorySubThumb godoc
//
// @Summary Get category sub thumbnail image
// @Tags category
// @Param id path string true "Category sub ID"
// @Success 200 file bytes
// @Failure 404
// @Router /category-sub/{id}/thumb [get]
func (c *Controller) CategorySubThumb(g *gin.Context) {
id := g.Param("id")
category := &model.CategorySub{ID: id}
result := c.datastore.First(category)
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
if !category.Thumbnail {
g.Redirect(http.StatusFound, CATEGORY_PROFILE_PICTURE_MISSING)
return
}
store, err := c.storageResolver.Storage(c.config.DefaultLibraryID)
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
filename := fmt.Sprintf("%s.jpg", id)
path := filepath.Join("categories", filename)
c.serveFromStorage(g, store, path)
}
package controller
import (
"net/http"
"path/filepath"
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/model"
)
// ChannelList godoc
//
// @Summary List all channels
// @Tags channel
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /channel [get]
func (c *Controller) ChannelList(g *gin.Context) {
var channels []model.Channel
c.datastore.Find(&channels)
g.JSON(http.StatusOK, gin.H{
"items": channels,
"total": len(channels),
})
}
// ChannelGet godoc
//
// @Summary Get channel by ID with videos
// @Tags channel
// @Produce json
// @Param id path string true "Channel ID"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Router /channel/{id} [get]
func (c *Controller) ChannelGet(g *gin.Context) {
id := g.Param("id")
channel := &model.Channel{ID: id}
result := c.datastore.First(channel)
if result.RowsAffected < 1 {
g.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
var videos []model.Video
c.datastore.Where("channel_id = ?", channel.ID).Find(&videos)
g.JSON(http.StatusOK, gin.H{
"channel": channel,
"videos": videos,
})
}
// ChannelCreate godoc
//
// @Summary Create a new channel
// @Tags channel
// @Accept json
// @Param body body object true "JSON with name"
// @Success 201 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /channel [post]
func (c *Controller) ChannelCreate(g *gin.Context) {
var body struct {
Name string `json:"name"`
}
if err := g.ShouldBindJSON(&body); err != nil {
g.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
channel := &model.Channel{Name: body.Name}
if err := c.datastore.Create(channel).Error; err != nil {
g.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
g.JSON(http.StatusCreated, gin.H{"id": channel.ID, "redirect": "/channel/" + channel.ID})
}
// ChannelUpdate godoc
//
// @Summary Update channel
// @Tags channel
// @Accept json
// @Param id path string true "Channel ID"
// @Param body body object true "JSON with optional name"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Router /channel/{id} [put]
func (c *Controller) ChannelUpdate(g *gin.Context) {
id := g.Param("id")
channel := &model.Channel{ID: id}
if result := c.datastore.First(channel); result.RowsAffected < 1 {
g.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
var body struct {
Name *string `json:"name"`
}
if err := g.ShouldBindJSON(&body); err != nil {
g.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if body.Name != nil {
channel.Name = *body.Name
}
if err := c.datastore.Save(channel).Error; err != nil {
g.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
g.JSON(http.StatusOK, channel)
}
// ChannelMap godoc
//
// @Summary Get channel ID to name map
// @Tags channel
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /channel/map [get]
func (c *Controller) ChannelMap(g *gin.Context) {
channels := []model.Channel{}
c.datastore.Find(&channels)
channelsJSON := make(map[string]string)
for _, channel := range channels {
channelsJSON[channel.ID] = channel.Name
}
g.JSON(http.StatusOK, gin.H{
"channels": channelsJSON,
})
}
// ChannelThumb godoc
//
// @Summary Get channel thumbnail image
// @Tags channel
// @Param id path string true "Channel ID"
// @Success 200 file bytes
// @Failure 404
// @Router /channel/{id}/thumb [get]
func (c *Controller) ChannelThumb(g *gin.Context) {
id := g.Param("id")
channel := &model.Channel{ID: id}
result := c.datastore.First(channel)
if result.RowsAffected < 1 {
c.ErrNotFound(g)
return
}
if !channel.Thumbnail {
g.Redirect(http.StatusFound, ACTOR_PROFILE_PICTURE_MISSING)
return
}
store, err := c.storageResolver.Storage(c.config.DefaultLibraryID)
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
path := filepath.Join("channels", id, "thumb.jpg")
c.serveFromStorage(g, store, path)
}
package controller
import (
"time"
"github.com/zobtube/zobtube/internal/model"
)
func (c *Controller) CleanupRoutine() {
go c.taskRestart()
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 {
c.logger.Warn().Err(err).Msg("error while querying sessions")
return
}
for _, session := range sessions {
if session.ValidUntil.Before(time.Now()) {
c.datastore.Delete(&session)
}
}
}
func (c *Controller) taskRestart() {
var tasks []model.Task
result := c.datastore.Where("status = ?", model.TaskStatusTodo).Find(&tasks)
err := result.Error
if err != nil {
c.logger.Warn().Err(err).Msg("error while querying tasks")
return
}
for _, task := range tasks {
c.logger.Warn().Str("kind", "tasks").Str("task-id", task.ID).Msg("stuck in todo, restarting")
c.runner.TaskRetry(task.Name)
}
}
package controller
import (
"math/rand"
"net/http"
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/model"
)
// ClipView godoc
//
// @Summary Get clip view data with video, actors, categories and clip list
// @Tags video
// @Produce json
// @Param id path string true "Clip ID"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Router /clip/{id} [get]
func (c *Controller) ClipView(g *gin.Context) {
id := g.Param("id")
video := &model.Video{ID: id}
result := c.datastore.Preload("Actors.Categories").Preload("Categories").First(video)
if result.RowsAffected < 1 {
g.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if video.Type != "c" {
g.JSON(http.StatusNotFound, gin.H{"error": "not a clip"})
return
}
type clipID struct{ ID string }
var clipIDs []clipID
c.datastore.Model(&model.Video{}).Where("type = ?", "c").Find(&clipIDs)
var clipList []string
for _, cid := range clipIDs {
if cid.ID != id {
clipList = append(clipList, cid.ID)
}
}
for i := range clipList {
j := rand.Intn(i + 1)
clipList[i], clipList[j] = clipList[j], clipList[i]
}
clipList = append([]string{id}, clipList...)
resp := gin.H{
"video": video,
"clip_ids": clipList,
}
if u := c.videoStreamURL(g, video); u != "" {
resp["stream_url"] = u
}
g.JSON(http.StatusOK, resp)
}
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
)
const ErrConfigAbsent = "configuration not found, restarting the appliaction should fix the issue"
func (c *Controller) ErrNotFound(g *gin.Context) {
g.JSON(http.StatusNotFound, gin.H{"error": "not found"})
}
// ErrUnauthorized godoc
//
// @Summary Unauthorized error response
// @Tags error
// @Produce json
// @Success 401 {object} map[string]interface{}
// @Router /error/unauthorized [get]
func (c *Controller) ErrUnauthorized(g *gin.Context) {
g.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
}
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/model"
)
// Home godoc
//
// @Summary List videos for home feed
// @Tags home
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /home [get]
func (c *Controller) Home(g *gin.Context) {
var videos []model.Video
c.datastore.Where("type = ?", "v").Order("created_at desc").Find(&videos)
g.JSON(http.StatusOK, gin.H{
"items": videos,
"total": len(videos),
})
}
package controller
import (
"crypto/sha256"
"encoding/hex"
"net/http"
"sort"
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/model"
)
type profileActorViewResult struct {
Actor model.Actor `json:"actor"`
Count int `json:"count"`
}
// ProfileView godoc
//
// @Summary Get user profile with top video views and actor stats
// @Tags profile
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /profile [get]
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
countPerActor := make(map[string]int)
var videoViewsAll []model.VideoView
c.datastore.Where("user_id = ?", user.ID).Find(&videoViewsAll)
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 {
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]]
})
var actorViews []profileActorViewResult
actorLimit := 12
for _, k := range keys {
if actorLimit <= 0 {
break
}
actorLimit--
actor := &model.Actor{ID: k}
c.datastore.First(actor)
actorViews = append(actorViews, profileActorViewResult{Actor: *actor, Count: countPerActor[k]})
}
g.JSON(http.StatusOK, gin.H{
"video_views": videoViewsTop,
"actor_views": actorViews,
})
}
// ProfileChangePassword godoc
//
// @Summary Change password for the authenticated user
// @Tags profile
// @Accept json
// @Param body body object true "JSON with current_password, new_password"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /profile/password [post]
func (c *Controller) ProfileChangePassword(g *gin.Context) {
user := g.MustGet("user").(*model.User)
var body struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
if err := g.ShouldBindJSON(&body); err != nil {
g.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
currentSum := sha256.Sum256([]byte(body.CurrentPassword))
currentHash := hex.EncodeToString(currentSum[:])
if currentHash != user.Password {
g.JSON(http.StatusBadRequest, gin.H{"error": "wrong current password"})
return
}
if body.NewPassword == "" {
g.JSON(http.StatusBadRequest, gin.H{"error": "new password cannot be empty"})
return
}
newSum := sha256.Sum256([]byte(body.NewPassword))
user.Password = hex.EncodeToString(newSum[:])
if err := c.datastore.Save(user).Error; err != nil {
g.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"})
return
}
g.JSON(http.StatusOK, gin.H{"ok": true})
}
package controller
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/model"
)
// ResolveUserByApiTokenHash looks up an API token by the hash of the raw token and returns the associated user if found.
// The tokenArg is the raw Bearer token; it will be hashed (SHA256) and looked up.
func (c *Controller) ResolveUserByApiTokenHash(tokenArg string) (*model.User, bool) {
if tokenArg == "" {
return nil, false
}
sum := sha256.Sum256([]byte(tokenArg))
hash := hex.EncodeToString(sum[:])
var apiToken model.ApiToken
if c.datastore.Where("token_hash = ?", hash).First(&apiToken).RowsAffected < 1 {
return nil, false
}
user := &model.User{ID: apiToken.UserID}
if c.datastore.First(user).RowsAffected < 1 {
return nil, false
}
return user, true
}
// ProfileTokenList returns the list of API tokens for the current user (id, name, created_at only).
func (c *Controller) ProfileTokenList(g *gin.Context) {
user := g.MustGet("user").(*model.User)
var tokens []model.ApiToken
c.datastore.Where("user_id = ?", user.ID).Order("created_at desc").Find(&tokens)
list := make([]gin.H, 0, len(tokens))
for _, t := range tokens {
list = append(list, gin.H{
"id": t.ID,
"name": t.Name,
"created_at": t.CreatedAt,
})
}
g.JSON(http.StatusOK, gin.H{"tokens": list})
}
// ProfileTokenCreate creates a new API token for the current user. Body: { "name": "label" }. Returns the raw token only in this response.
func (c *Controller) ProfileTokenCreate(g *gin.Context) {
user := g.MustGet("user").(*model.User)
var body struct {
Name string `json:"name"`
}
if err := g.ShouldBindJSON(&body); err != nil {
g.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
name := body.Name
if name == "" {
g.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
g.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
return
}
rawToken := hex.EncodeToString(raw)
sum := sha256.Sum256([]byte(rawToken))
tokenHash := hex.EncodeToString(sum[:])
apiToken := &model.ApiToken{
UserID: user.ID,
Name: name,
TokenHash: tokenHash,
CreatedAt: time.Now(),
}
if err := c.datastore.Create(apiToken).Error; err != nil {
g.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"})
return
}
g.JSON(http.StatusCreated, gin.H{
"id": apiToken.ID,
"name": apiToken.Name,
"token": rawToken,
"created_at": apiToken.CreatedAt,
})
}
// ProfileTokenDelete deletes an API token by ID if it belongs to the current user.
func (c *Controller) ProfileTokenDelete(g *gin.Context) {
user := g.MustGet("user").(*model.User)
id := g.Param("id")
if id == "" {
g.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
return
}
var apiToken model.ApiToken
if c.datastore.Where("id = ? AND user_id = ?", id, user.ID).First(&apiToken).RowsAffected < 1 {
g.JSON(http.StatusNotFound, gin.H{"error": "token not found"})
return
}
if err := c.datastore.Delete(&apiToken).Error; err != nil {
g.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete"})
return
}
g.AbortWithStatus(http.StatusNoContent)
}
// AdmTokenList returns all API tokens (admin only) with id, name, created_at, user_id, username.
func (c *Controller) AdmTokenList(g *gin.Context) {
var tokens []model.ApiToken
c.datastore.Order("created_at desc").Find(&tokens)
if len(tokens) == 0 {
g.JSON(http.StatusOK, gin.H{"tokens": []gin.H{}})
return
}
userIDs := make([]string, 0, len(tokens))
seen := make(map[string]struct{})
for _, t := range tokens {
if _, ok := seen[t.UserID]; !ok {
seen[t.UserID] = struct{}{}
userIDs = append(userIDs, t.UserID)
}
}
var users []model.User
c.datastore.Where("id IN ?", userIDs).Find(&users)
userMap := make(map[string]string)
for _, u := range users {
userMap[u.ID] = u.Username
}
list := make([]gin.H, 0, len(tokens))
for _, t := range tokens {
list = append(list, gin.H{
"id": t.ID,
"name": t.Name,
"created_at": t.CreatedAt,
"user_id": t.UserID,
"username": userMap[t.UserID],
})
}
g.JSON(http.StatusOK, gin.H{"tokens": list})
}
// AdmTokenDelete deletes an API token by ID (admin only). Returns 204 on success, 404 if not found.
func (c *Controller) AdmTokenDelete(g *gin.Context) {
id := g.Param("id")
if id == "" {
g.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
return
}
var apiToken model.ApiToken
if c.datastore.Where("id = ?", id).First(&apiToken).RowsAffected < 1 {
g.JSON(http.StatusNotFound, gin.H{"error": "token not found"})
return
}
if c.datastore.Delete(&apiToken).Error != nil {
g.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete"})
return
}
g.AbortWithStatus(http.StatusNoContent)
}
package controller
import (
"errors"
"github.com/zobtube/zobtube/internal/model"
"github.com/zobtube/zobtube/internal/provider"
)
func (c *Controller) ProviderRegister(p provider.Provider) error {
subLog := c.logger.With().Str("kind", "provider").Str("provider", p.SlugGet()).Logger()
subLog.Debug().Msg("register provider")
c.providers[p.SlugGet()] = p
_provider := &model.Provider{
ID: p.SlugGet(),
}
result := c.datastore.First(_provider)
// check result
if result.RowsAffected == 1 {
// provider already registered, updating it if needed
_provider.NiceName = p.NiceName()
_provider.AbleToSearchActor = p.CapabilitySearchActor()
_provider.AbleToScrapePicture = p.CapabilityScrapePicture()
} else {
// provider not registered, creating it now
subLog.Info().Msg("first time seeing provider, creating its configuration")
_provider = &model.Provider{
ID: p.SlugGet(),
Enabled: true,
NiceName: p.NiceName(),
AbleToSearchActor: p.CapabilitySearchActor(),
AbleToScrapePicture: p.CapabilityScrapePicture(),
}
}
err := c.datastore.Save(&_provider).Error
if err != nil {
subLog.Error().Err(err).Msg("unable to create configuration")
return err
}
return nil
}
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.logger.Warn().Str("kind", "system").Msg("shutdown requested")
c.shutdownChannel <- 1
}
func (c *Controller) Restart() {
c.logger.Warn().Str("kind", "system").Msg("restart requested")
c.shutdownChannel <- 2
}
package controller
import (
"net/http"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/model"
"github.com/zobtube/zobtube/internal/storage"
)
// serveFromStorage serves the object at path from the given storage (filesystem or S3).
func (c *Controller) serveFromStorage(g *gin.Context, store storage.Storage, path string) {
if store == nil {
g.JSON(http.StatusInternalServerError, gin.H{"error": "storage not available"})
return
}
if fs, ok := store.(*storage.Filesystem); ok {
g.File(fs.FullPath(path))
return
}
rc, err := store.Open(path)
if err != nil {
g.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer rc.Close()
contentType := "application/octet-stream"
switch filepath.Ext(path) {
case ".mp4", ".webm", ".mkv":
contentType = "video/mp4"
case ".jpg", ".jpeg":
contentType = "image/jpeg"
case ".png":
contentType = "image/png"
}
g.DataFromReader(http.StatusOK, -1, contentType, rc, nil)
}
// videoLibraryID returns the library ID for the video (or default if unset).
func (c *Controller) videoLibraryID(video *model.Video) string {
if video.LibraryID != nil && *video.LibraryID != "" {
return *video.LibraryID
}
return c.config.DefaultLibraryID
}
// videoStreamURL returns a direct stream URL when the video's storage supports it (e.g. S3 presigned).
// Otherwise returns empty string; frontend falls back to /api/video/:id/stream.
func (c *Controller) videoStreamURL(g *gin.Context, video *model.Video) string {
if c.storageResolver == nil {
return ""
}
store, err := c.storageResolver.Storage(c.videoLibraryID(video))
if err != nil {
return ""
}
ps, ok := store.(storage.PreviewableStorage)
if !ok {
return ""
}
var path string
if video.Imported {
path = video.RelativePath()
} else {
path = filepath.Join("triage", video.Filename)
}
url, err := ps.PresignGet(g.Request.Context(), path, time.Hour)
if err != nil || url == "" {
return ""
}
return url
}
package controller
import (
"github.com/gin-gonic/gin"
"github.com/rs/zerolog"
"gorm.io/gorm"
"github.com/zobtube/zobtube/internal/config"
"github.com/zobtube/zobtube/internal/model"
"github.com/zobtube/zobtube/internal/provider"
"github.com/zobtube/zobtube/internal/runner"
"github.com/zobtube/zobtube/internal/storage"
"github.com/zobtube/zobtube/internal/swagger"
)
// StorageResolver is the minimal interface for resolving storage by library ID (used by controller and tests).
type StorageResolver interface {
Storage(libraryID string) (storage.Storage, error)
Invalidate(libraryID string)
}
type AbstractController interface {
// Back office
AdmHome(*gin.Context)
AdmVideoList(*gin.Context)
AdmActorList(*gin.Context)
AdmChannelList(*gin.Context)
AdmCategory(*gin.Context)
AdmConfigAuth(*gin.Context)
AdmConfigAuthUpdate(*gin.Context)
AdmConfigProvider(*gin.Context)
AdmConfigProviderSwitch(*gin.Context)
AdmConfigOfflineMode(*gin.Context)
AdmConfigOfflineModeUpdate(*gin.Context)
AdmTaskHome(*gin.Context)
AdmTaskList(*gin.Context)
AdmTaskRetry(*gin.Context)
AdmTaskView(*gin.Context)
AdmUserList(*gin.Context)
AdmUserNew(*gin.Context)
AdmUserDelete(*gin.Context)
AdmTokenList(*gin.Context)
AdmTokenDelete(*gin.Context)
AdmLibraryList(*gin.Context)
AdmLibraryCreate(*gin.Context)
AdmLibraryUpdate(*gin.Context)
AdmLibraryDelete(*gin.Context)
// Home
Home(*gin.Context)
// Auth
AuthenticationEnabled() bool
AuthLogin(*gin.Context)
AuthLogout(*gin.Context)
AuthLogoutRedirect(*gin.Context)
AuthMe(*gin.Context)
GetSession(*model.UserSession) *gorm.DB
GetUser(*model.User) *gorm.DB
GetFirstUser(*model.User) *gorm.DB
// Actors
ActorCategories(*gin.Context)
ActorLinkThumbGet(*gin.Context)
ActorLinkThumbDelete(*gin.Context)
ActorNew(*gin.Context)
ActorProviderSearch(*gin.Context)
ActorRename(*gin.Context)
ActorDescription(*gin.Context)
ActorUploadThumb(*gin.Context)
ActorLinkCreate(*gin.Context)
ActorAliasCreate(*gin.Context)
ActorAliasRemove(*gin.Context)
ActorMerge(*gin.Context)
ActorList(*gin.Context)
ActorGet(*gin.Context)
ActorDelete(*gin.Context)
ActorThumb(*gin.Context)
// Categories
CategoryAdd(*gin.Context)
CategoryDelete(*gin.Context)
CategoryList(*gin.Context)
CategorySubGet(*gin.Context)
// Sub categories
CategorySubAdd(*gin.Context)
CategorySubRename(*gin.Context)
CategorySubThumbSet(*gin.Context)
CategorySubThumbRemove(*gin.Context)
CategorySubThumb(*gin.Context)
// Video, used for Clips, Movies and Videos
VideoGet(*gin.Context)
VideoActors(*gin.Context)
VideoCategories(*gin.Context)
VideoRename(*gin.Context)
VideoUpload(*gin.Context)
VideoUploadThumb(*gin.Context)
VideoCreate(*gin.Context)
VideoStreamInfo(*gin.Context)
VideoDelete(*gin.Context)
VideoMigrate(*gin.Context)
VideoGenerateThumbnail(*gin.Context)
VideoEditChannel(*gin.Context)
VideoEditLibrary(*gin.Context)
VideoStream(*gin.Context)
VideoThumb(*gin.Context)
VideoThumbXS(*gin.Context)
// Video Views
VideoViewIncrement(*gin.Context)
ClipList(*gin.Context)
ClipView(*gin.Context)
MovieList(*gin.Context)
VideoList(*gin.Context)
VideoView(*gin.Context)
VideoEdit(*gin.Context)
// Channels
ChannelList(*gin.Context)
ChannelGet(*gin.Context)
ChannelCreate(*gin.Context)
ChannelUpdate(*gin.Context)
ChannelThumb(*gin.Context)
ChannelMap(*gin.Context)
// Uploads
UploadImport(*gin.Context)
UploadPreview(*gin.Context)
UploadTriageFolder(*gin.Context)
UploadTriageFile(*gin.Context)
UploadFile(*gin.Context)
UploadDeleteFile(*gin.Context)
UploadFolderCreate(*gin.Context)
UploadMassDelete(*gin.Context)
UploadMassImport(*gin.Context)
// Providers
ProviderRegister(provider.Provider) error
ProviderGet(string) (provider.Provider, error)
// Profile
ProfileView(*gin.Context)
ProfileChangePassword(*gin.Context)
ResolveUserByApiTokenHash(string) (*model.User, bool)
ProfileTokenList(*gin.Context)
ProfileTokenCreate(*gin.Context)
ProfileTokenDelete(*gin.Context)
// Error pages
ErrNotFound(*gin.Context)
ErrUnauthorized(*gin.Context)
// SPA
Bootstrap(*gin.Context)
SPAApp(*gin.Context)
NoRouteOrSPA(*gin.Context)
// Init
LoggerRegister(*zerolog.Logger)
ConfigurationRegister(*config.Config)
DatabaseRegister(*gorm.DB)
RunnerRegister(*runner.Runner)
ConfigurationFromDBApply(*model.Configuration)
BuildDetailsRegister(string, string, string)
RegisterError(string)
StorageResolverRegister(StorageResolver)
// Cleanup
CleanupRoutine()
// Maintenance
Restart()
Shutdown()
}
type buildDetails struct {
Version string
Commit string
BuildDate string
}
type Controller struct {
config *config.Config
datastore *gorm.DB
storageResolver StorageResolver
providers map[string]provider.Provider
shutdownChannel chan<- int
runner *runner.Runner
build *buildDetails
logger *zerolog.Logger
healthError []string
}
func New(shutdownChannel chan int) AbstractController {
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) ConfigurationFromDBApply(db *model.Configuration) {
c.logger.Info().Str("kind", "system").Bool("authentication", db.UserAuthentication).Send()
c.config.Authentication = db.UserAuthentication
}
func (c *Controller) LoggerRegister(logger *zerolog.Logger) {
c.logger = logger
}
func (c *Controller) BuildDetailsRegister(version, commit, buildDate string) {
c.build = &buildDetails{
Version: version,
Commit: commit,
BuildDate: buildDate,
}
swagger.SwaggerInfo.Version = version
}
func (c *Controller) RegisterError(err string) {
c.healthError = append(c.healthError, err)
}
func (c *Controller) StorageResolverRegister(r StorageResolver) {
c.storageResolver = r
}
package controller
import (
"fmt"
"io"
"net/http"
"net/url"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/model"
)
const errFileEmpty = "file name cannot be empty"
// uploadLibraryID returns the library ID to use for upload/triage: if formLibraryID is non-empty
// and a library with that ID exists, returns it; otherwise returns the default library ID.
func (c *Controller) uploadLibraryID(formLibraryID string) string {
if formLibraryID == "" {
return c.config.DefaultLibraryID
}
var lib model.Library
if c.datastore.First(&lib, "id = ?", formLibraryID).RowsAffected < 1 {
return c.config.DefaultLibraryID
}
return lib.ID
}
//
// @Summary Import file from triage as video
// @Tags upload
// @Accept json
// @Param body body object true "JSON with path, import_as"
// @Success 201 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /upload/import [post]
func (c *Controller) UploadImport(g *gin.Context) {
var body struct {
Path string `json:"path"`
ImportAs string `json:"import_as"`
LibraryID string `json:"library_id"`
}
if err := g.ShouldBindJSON(&body); err != nil {
g.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
libID := c.uploadLibraryID(body.LibraryID)
video := &model.Video{
Name: body.Path,
Filename: body.Path,
Thumbnail: false,
ThumbnailMini: false,
Type: body.ImportAs,
LibraryID: &libID,
}
if err := c.datastore.Create(video).Error; err != nil {
g.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
g.JSON(http.StatusCreated, gin.H{"id": video.ID, "redirect": "/video/" + video.ID})
}
// UploadPreview godoc
//
// @Summary Preview file from triage folder
// @Tags upload
// @Param filepath path string true "URL-encoded file path"
// @Success 200 file bytes
// @Failure 500 {object} map[string]interface{}
// @Router /upload/preview/{filepath} [get]
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
}
libID := c.uploadLibraryID(g.Query("library_id"))
store, err := c.storageResolver.Storage(libID)
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
path := filepath.Join("triage", filePath)
c.serveFromStorage(g, store, path)
}
// UploadTriageFolder godoc
//
// @Summary List folders in triage path with file counts
// @Tags upload
// @Accept x-www-form-urlencoded
// @Param path formData string true "Path in triage"
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /upload/triage/folder [post]
func (c *Controller) UploadTriageFolder(g *gin.Context) {
path := g.PostForm("path")
libID := c.uploadLibraryID(g.PostForm("library_id"))
store, err := c.storageResolver.Storage(libID)
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
prefix := filepath.Join("triage", path)
entries, err := store.List(prefix)
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
items := make(map[string]int)
for _, e := range entries {
if !e.IsDir {
continue
}
subPrefix := filepath.Join(prefix, e.Name)
sub, err := store.List(subPrefix)
if err != nil {
continue
}
count := 0
for _, s := range sub {
if !s.IsDir {
count++
}
}
items[e.Name] = count
}
g.JSON(http.StatusOK, gin.H{"folders": items})
}
type FileInfo struct {
Size int64
LastModification time.Time
}
// UploadTriageFile godoc
//
// @Summary List files in triage path
// @Tags upload
// @Accept x-www-form-urlencoded
// @Param path formData string true "Path in triage"
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /upload/triage/file [post]
func (c *Controller) UploadTriageFile(g *gin.Context) {
path := g.PostForm("path")
libID := c.uploadLibraryID(g.PostForm("library_id"))
store, err := c.storageResolver.Storage(libID)
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
prefix := filepath.Join("triage", path)
entries, err := store.List(prefix)
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
items := make(map[string]FileInfo)
for _, e := range entries {
if e.IsDir {
continue
}
items[e.Name] = FileInfo{
Size: e.Size,
LastModification: e.ModTime,
}
}
g.JSON(http.StatusOK, gin.H{"files": items})
}
// UploadFile godoc
//
// @Summary Upload file to triage folder
// @Tags upload
// @Accept multipart/form-data
// @Param file formData file true "File to upload"
// @Param path formData string true "Destination path"
// @Success 200
// @Failure 500 {object} map[string]interface{}
// @Router /upload/file [post]
func (c *Controller) UploadFile(g *gin.Context) {
file, err := g.FormFile("file")
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
_path := g.PostForm("path")
path := filepath.Join("triage", _path, file.Filename)
libID := c.uploadLibraryID(g.PostForm("library_id"))
store, err := c.storageResolver.Storage(libID)
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
if err := store.MkdirAll(filepath.Dir(path)); err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
src, err := file.Open()
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
defer src.Close()
dst, err := store.Create(path)
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
g.JSON(200, gin.H{})
}
// UploadDeleteFile godoc
//
// @Summary Delete file from triage
// @Tags upload
// @Param File formData string true "File path in triage"
// @Success 200
// @Failure 400 {object} map[string]interface{}
// @Router /upload/file [delete]
func (c *Controller) UploadDeleteFile(g *gin.Context) {
type fileDeleteForm struct {
File string `json:"File"`
LibraryID string `json:"library_id"`
}
form := fileDeleteForm{}
if err := g.ShouldBindJSON(&form); err != nil {
g.JSON(400, gin.H{"error": err.Error()})
return
}
if form.File == "" {
g.JSON(400, gin.H{"error": errFileEmpty})
return
}
libID := c.uploadLibraryID(form.LibraryID)
store, err := c.storageResolver.Storage(libID)
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
path := filepath.Join("triage", form.File)
if err := store.Delete(path); err != nil {
g.JSON(422, gin.H{"error": err.Error()})
return
}
g.JSON(200, gin.H{})
}
// UploadMassDelete godoc
//
// @Summary Delete multiple files from triage
// @Tags upload
// @Accept json
// @Param body body object true "JSON with files array"
// @Success 200
// @Failure 400 {object} map[string]interface{}
// @Router /upload/triage/mass-action [delete]
func (c *Controller) UploadMassDelete(g *gin.Context) {
// get file list from request
type fileDeleteForm struct {
Files []string `json:"files" binding:"required"`
LibraryID string `json:"library_id"`
}
form := fileDeleteForm{}
err := g.ShouldBindJSON(&form)
if err != nil {
g.JSON(400, gin.H{
"error": err.Error(),
})
return
}
// ensure not empty
files := form.Files
if len(files) == 0 {
g.JSON(400, gin.H{
"error": "mass deletion requested without any files",
})
return
}
libID := c.uploadLibraryID(form.LibraryID)
for _, file := range files {
c.logger.Debug().Str("file", file).Send()
if file == "" {
g.JSON(400, gin.H{"error": errFileEmpty})
return
}
store, err := c.storageResolver.Storage(libID)
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
path := filepath.Join("triage", file)
if err := store.Delete(path); err != nil {
g.JSON(422, gin.H{"error": err.Error()})
return
}
}
g.JSON(200, gin.H{})
}
// UploadMassImport godoc
//
// @Summary Mass import files from triage as videos
// @Tags upload
// @Accept json
// @Param body body object true "JSON with files, type, actors, categories, channel"
// @Success 200
// @Failure 400 {object} map[string]interface{}
// @Router /upload/triage/mass-action [post]
func (c *Controller) UploadMassImport(g *gin.Context) {
// get file list from request
type fileImportForm struct {
Files []string `json:"files" binding:"required"`
Actors []string `json:"actors"`
Categories []string `json:"categories"`
TypeEnum string `json:"type" binding:"required"`
Channel string `json:"channel"`
LibraryID string `json:"library_id"`
}
form := fileImportForm{}
err := g.ShouldBindJSON(&form)
if err != nil {
g.JSON(400, gin.H{
"error": err.Error(),
})
return
}
// ensure type is valid
if form.TypeEnum != "c" && form.TypeEnum != "v" && form.TypeEnum != "m" {
g.JSON(400, gin.H{
"error": "type of video is invalid",
})
}
// ensure not empty
files := form.Files
if len(files) == 0 {
g.JSON(400, gin.H{
"error": "mass import requested without any files",
})
return
}
// pre-check: ensure actors exists
var actors []*model.Actor
for _, actorID := range form.Actors {
actor := &model.Actor{
ID: actorID,
}
result := c.datastore.First(actor)
// check result
if result.RowsAffected < 1 {
g.JSON(400, gin.H{
"error": fmt.Sprintf("actor id %s does not exist", actorID),
})
return
}
actors = append(actors, actor)
}
// pre-check: ensure categories exists
var categories []*model.CategorySub
for _, subCategoryID := range form.Categories {
subCategory := &model.CategorySub{
ID: subCategoryID,
}
result := c.datastore.First(subCategory)
// check result
if result.RowsAffected < 1 {
g.JSON(400, gin.H{
"error": fmt.Sprintf("category id %s does not exist", subCategoryID),
})
return
}
categories = append(categories, subCategory)
}
// pre-check: ensure channel exists
var channel *model.Channel
channel = nil
if form.Channel != "" {
channel = &model.Channel{
ID: form.Channel,
}
result := c.datastore.First(channel)
// check result
if result.RowsAffected < 1 {
g.JSON(400, gin.H{
"error": fmt.Sprintf("channel id %s does not exist", form.Channel),
})
return
}
}
// pre-check: ensure files are not empty
for _, file := range files {
c.logger.Debug().Str("file", file).Send()
if file == "" {
g.JSON(400, gin.H{
"error": errFileEmpty,
})
return
}
}
// prepare transaction for the whole import
tx := c.datastore.Begin()
var videos []*model.Video
libID := c.uploadLibraryID(form.LibraryID)
// now perform file import
for _, file := range files {
video := &model.Video{
Name: file,
Filename: file,
Type: form.TypeEnum,
Imported: false,
Thumbnail: false,
LibraryID: &libID,
}
if channel != nil {
video.Channel = channel
}
// save object in db
err = tx.Create(&video).Error
if err != nil {
tx.Rollback()
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
err = tx.Model(video).Association("Actors").Append(actors)
if err != nil {
tx.Rollback()
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
err = tx.Model(video).Association("Categories").Append(categories)
if err != nil {
tx.Rollback()
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
videos = append(videos, video)
}
// validate transaction
tx.Commit()
// now create task for the import
for _, video := range videos {
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{})
}
// UploadFolderCreate godoc
//
// @Summary Create folder in triage
// @Tags upload
// @Accept x-www-form-urlencoded
// @Param name formData string true "Folder name"
// @Success 200
// @Failure 409 {object} map[string]interface{}
// @Router /upload/folder [post]
func (c *Controller) UploadFolderCreate(g *gin.Context) {
name := g.PostForm("name")
libID := c.uploadLibraryID(g.PostForm("library_id"))
store, err := c.storageResolver.Storage(libID)
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
path := filepath.Join("triage", name)
exists, err := store.Exists(path)
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
if exists {
g.JSON(409, gin.H{"error": "Folder already exists"})
return
}
if err := store.MkdirAll(path); err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
g.JSON(200, gin.H{})
}
package controller
import (
"io"
"net/http"
"path/filepath"
"github.com/gin-gonic/gin"
"gorm.io/gorm/clause"
"github.com/zobtube/zobtube/internal/model"
)
// VideoList godoc
//
// @Summary List all videos
// @Tags video
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /video [get]
func (c *Controller) VideoList(g *gin.Context) {
var videos []model.Video
c.datastore.Where("type = ?", "v").Order("created_at desc").Find(&videos)
g.JSON(http.StatusOK, gin.H{"items": videos, "total": len(videos)})
}
// ClipList godoc
//
// @Summary List all clips
// @Tags video
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /clip [get]
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.JSON(http.StatusOK, gin.H{"items": videos, "total": len(videos)})
}
// MovieList godoc
//
// @Summary List all movies
// @Tags video
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /movie [get]
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.JSON(http.StatusOK, gin.H{"items": videos, "total": len(videos)})
}
// VideoView godoc
//
// @Summary Get video view page data
// @Tags video
// @Produce json
// @Param id path string true "Video ID"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Router /video/{id} [get]
func (c *Controller) VideoView(g *gin.Context) {
id := g.Param("id")
video := &model.Video{ID: id}
result := c.datastore.Preload("Actors.Categories").Preload("Channel").Preload("Categories").First(video)
if result.RowsAffected < 1 {
g.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
viewCount := 0
user, ok := g.Get("user")
if ok {
if u, ok := user.(*model.User); ok && u != nil && u.ID != "" {
count := &model.VideoView{}
if c.datastore.First(count, "video_id = ? AND user_id = ?", video.ID, u.ID).RowsAffected > 0 {
viewCount = count.Count
}
}
}
categories := make(map[string]string)
for _, cat := range video.Categories {
categories[cat.ID] = cat.Name
}
for _, actor := range video.Actors {
for _, cat := range actor.Categories {
categories[cat.ID] = cat.Name
}
}
var randomVideos []model.Video
c.datastore.Limit(8).Where("type = ? and id != ?", video.Type, video.ID).Order("RANDOM()").Find(&randomVideos)
resp := gin.H{
"video": video,
"view_count": viewCount,
"categories": categories,
"random_videos": randomVideos,
}
if u := c.videoStreamURL(g, video); u != "" {
resp["stream_url"] = u
}
g.JSON(http.StatusOK, resp)
}
// VideoEdit godoc
//
// @Summary Get video edit form data (admin)
// @Tags video
// @Produce json
// @Param id path string true "Video ID"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Router /video/{id}/edit [get]
func (c *Controller) VideoEdit(g *gin.Context) {
id := g.Param("id")
video := &model.Video{ID: id}
result := c.datastore.Preload("Actors").Preload("Channel").Preload("Categories").First(video)
if result.RowsAffected < 1 {
g.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
var actors []model.Actor
c.datastore.Find(&actors)
var categories []model.Category
c.datastore.Preload("Sub").Find(&categories)
var libraries []model.Library
c.datastore.Order("created_at").Find(&libraries)
resp := gin.H{
"video": video,
"actors": actors,
"categories": categories,
"libraries": libraries,
}
if u := c.videoStreamURL(g, video); u != "" {
resp["stream_url"] = u
}
g.JSON(http.StatusOK, resp)
}
// VideoActors godoc
//
// @Summary Add or remove actor from video (PUT=add, DELETE=remove)
// @Tags video
// @Param id path string true "Video ID"
// @Param actor_id path string true "Actor ID"
// @Success 200
// @Failure 404 {object} map[string]interface{}
// @Router /video/{id}/actor/{actor_id} [put]
// @Router /video/{id}/actor/{actor_id} [delete]
func (c *Controller) VideoActors(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{})
}
// VideoCategories godoc
//
// @Summary Add or remove category from video (PUT=add, DELETE=remove)
// @Tags video
// @Param id path string true "Video ID"
// @Param category_id path string true "Category (sub) ID"
// @Success 200
// @Failure 404 {object} map[string]interface{}
// @Router /video/{id}/category/{category_id} [put]
// @Router /video/{id}/category/{category_id} [delete]
func (c *Controller) VideoCategories(g *gin.Context) {
// get id from path
id := g.Param("id")
category_id := g.Param("category_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{
"error": "video not found",
})
return
}
subCategory := &model.CategorySub{
ID: category_id,
}
result = c.datastore.First(&subCategory)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{
"error": "sub-category not found",
})
return
}
var res error
if g.Request.Method == "PUT" {
res = c.datastore.Model(video).Association("Categories").Append(subCategory)
} else {
res = c.datastore.Model(video).Association("Categories").Delete(subCategory)
}
if res != nil {
g.JSON(500, gin.H{
"error": res.Error(),
})
return
}
g.JSON(200, gin.H{})
}
// VideoStreamInfo godoc
//
// @Summary Get video stream info (HEAD request)
// @Tags video
// @Param id path string true "Video ID"
// @Success 200
// @Failure 404
// @Router /video/{id} [head]
func (c *Controller) VideoStreamInfo(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
}
libID := c.videoLibraryID(video)
store, err := c.storageResolver.Storage(libID)
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
path := video.RelativePath()
exists, err := store.Exists(path)
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
if exists {
g.JSON(200, gin.H{})
} else {
g.JSON(404, gin.H{})
}
}
type VideoRenameForm struct {
Name string `form:"name"`
}
// VideoRename godoc
//
// @Summary Rename video
// @Tags video
// @Accept x-www-form-urlencoded
// @Param id path string true "Video ID"
// @Param name formData string true "New name"
// @Success 200
// @Failure 404 {object} map[string]interface{}
// @Router /video/{id}/rename [post]
func (c *Controller) VideoRename(g *gin.Context) {
if g.Request.Method != "POST" {
// method not allowed
g.JSON(405, gin.H{})
return
}
var form VideoRenameForm
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
err = c.datastore.Save(video).Error
if err != nil {
g.JSON(500, gin.H{
"error": err.Error(),
})
return
}
g.JSON(200, gin.H{})
}
// VideoCreate godoc
//
// @Summary Create a new video
// @Tags video
// @Accept multipart/form-data
// @Param name formData string true "Video name"
// @Param filename formData string true "Filename in triage"
// @Param type formData string true "Type: c (clip), m (movie), v (video)"
// @Param actors formData array false "Actor IDs"
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /video [post]
func (c *Controller) VideoCreate(g *gin.Context) {
var err error
form := struct {
Name string `form:"name"`
Filename string `form:"filename"`
Actors []string `form:"actors"`
TypeEnum string `form:"type"`
LibraryID string `form:"library_id"`
}{}
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
}
libID := c.uploadLibraryID(form.LibraryID)
video := &model.Video{
Name: form.Name,
Filename: form.Filename,
Type: form.TypeEnum,
Imported: false,
Thumbnail: false,
LibraryID: &libID,
}
// 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,
})
}
// VideoUploadThumb godoc
//
// @Summary Upload video thumbnail
// @Tags video
// @Accept multipart/form-data
// @Param id path string true "Video ID"
// @Param thumbnail formData file true "Thumbnail image"
// @Success 200
// @Failure 404 {object} map[string]interface{}
// @Router /video/{id}/thumb [post]
func (c *Controller) VideoUploadThumb(g *gin.Context) {
id := g.Param("id")
video := &model.Video{ID: id}
result := c.datastore.First(video)
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
store, err := c.storageResolver.Storage(c.videoLibraryID(video))
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
if err := store.MkdirAll(video.FolderRelativePath()); err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
thumbnail, err := g.FormFile("thumbnail")
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
src, err := thumbnail.Open()
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
defer src.Close()
dst, err := store.Create(video.ThumbnailRelativePath())
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
video.Thumbnail = true
if err := c.datastore.Save(video).Error; err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
if err := c.runner.NewTask("video/mini-thumb", map[string]string{"videoID": video.ID}); err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
g.JSON(200, gin.H{})
}
// VideoUpload godoc
//
// @Summary Upload video file
// @Tags video
// @Accept multipart/form-data
// @Param id path string true "Video ID"
// @Param file formData file true "Video file"
// @Success 200
// @Failure 404 {object} map[string]interface{}
// @Router /video/{id}/upload [post]
func (c *Controller) VideoUpload(g *gin.Context) {
id := g.Param("id")
video := &model.Video{ID: id}
result := c.datastore.First(video)
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
store, err := c.storageResolver.Storage(c.videoLibraryID(video))
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
if err := store.MkdirAll(video.FolderRelativePath()); err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
videoData, err := g.FormFile("file")
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
src, err := videoData.Open()
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
defer src.Close()
dst, err := store.Create(video.RelativePath())
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
video.Imported = true
if err := c.datastore.Save(video).Error; err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
g.JSON(200, gin.H{})
}
// VideoDelete godoc
//
// @Summary Delete a video
// @Tags video
// @Param id path string true "Video ID"
// @Success 200
// @Failure 404 {object} map[string]interface{}
// @Router /video/{id} [delete]
func (c *Controller) VideoDelete(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{})
}
// VideoMigrate godoc
//
// @Summary Migrate video to different type (c/m/v)
// @Tags video
// @Accept x-www-form-urlencoded
// @Param id path string true "Video ID"
// @Param new_type formData string true "New type: c, m, or v"
// @Success 200
// @Failure 404 {object} map[string]interface{}
// @Router /video/{id}/migrate [post]
func (c *Controller) VideoMigrate(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")
oldFolder := video.FolderRelativePathForType(video.Type)
video.Type = newType
newFolder := video.FolderRelativePath()
store, err := c.storageResolver.Storage(c.videoLibraryID(video))
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
// Copy known files from old folder to new folder
for _, name := range []string{"video.mp4", "thumb.jpg", "thumb-xs.jpg"} {
oldPath := filepath.Join(oldFolder, name)
newPath := filepath.Join(newFolder, name)
exists, _ := store.Exists(oldPath)
if !exists {
continue
}
rc, err := store.Open(oldPath)
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
wc, err := store.Create(newPath)
if err != nil {
rc.Close()
g.JSON(500, gin.H{"error": err.Error()})
return
}
_, err = io.Copy(wc, rc)
rc.Close()
wc.Close()
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
_ = store.Delete(oldPath)
}
if err := c.datastore.Save(video).Error; err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
g.JSON(200, gin.H{})
}
// VideoGenerateThumbnail godoc
//
// @Summary Generate video thumbnail at given timing
// @Tags video
// @Param id path string true "Video ID"
// @Param timing path string true "Timing in seconds"
// @Success 200
// @Failure 404 {object} map[string]interface{}
// @Failure 409 {object} map[string]interface{}
// @Router /video/{id}/generate-thumbnail/{timing} [post]
func (c *Controller) VideoGenerateThumbnail(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) VideoEditChannel(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{})
}
// VideoEditLibrary godoc
//
// @Summary Change video library (admin)
// @Tags video
// @Accept json
// @Param id path string true "Video ID"
// @Param body body object true "JSON with library_id"
// @Success 200
// @Failure 400 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Router /video/{id}/library [post]
func (c *Controller) VideoEditLibrary(g *gin.Context) {
id := g.Param("id")
var body struct {
LibraryID string `json:"library_id" binding:"required"`
}
if err := g.ShouldBindJSON(&body); err != nil {
g.JSON(http.StatusBadRequest, gin.H{"error": "library_id required"})
return
}
video := &model.Video{ID: id}
if c.datastore.First(video).RowsAffected < 1 {
g.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
var lib model.Library
if c.datastore.First(&lib, "id = ?", body.LibraryID).RowsAffected < 1 {
g.JSON(http.StatusBadRequest, gin.H{"error": "library not found"})
return
}
sourceLibID := c.videoLibraryID(video)
if sourceLibID == lib.ID {
g.JSON(http.StatusOK, gin.H{})
return
}
params := map[string]string{
"videoID": video.ID,
"targetLibraryID": lib.ID,
"sourceLibraryID": sourceLibID,
}
if err := c.runner.NewTask("video/move-library", params); err != nil {
g.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
g.JSON(http.StatusOK, gin.H{})
}
// VideoGet godoc
//
// @Summary Get video summary (title, actors, categories)
// @Tags video
// @Produce json
// @Param id path string true "Video ID"
// @Success 200 {object} map[string]interface{}
// @Failure 404
// @Router /video/{id}/summary [get]
func (c *Controller) VideoGet(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.Categories").Preload("Categories").First(video)
// check result
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
var actors []string
var categories []string
for _, actor := range video.Actors {
actors = append(actors, actor.Name)
for _, category := range actor.Categories {
categories = append(categories, category.Name)
}
}
for _, category := range video.Categories {
categories = append(categories, category.Name)
}
g.JSON(200, gin.H{
"title": video.Name,
"actors": actors,
"categories": categories,
})
}
// VideoStream godoc
//
// @Summary Stream video file
// @Tags video
// @Param id path string true "Video ID"
// @Success 200 file bytes
// @Failure 404
// @Router /video/{id}/stream [get]
func (c *Controller) VideoStream(g *gin.Context) {
id := g.Param("id")
video := &model.Video{ID: id}
result := c.datastore.First(video)
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
libID := c.videoLibraryID(video)
store, err := c.storageResolver.Storage(libID)
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
var path string
if video.Imported {
path = video.RelativePath()
} else {
path = filepath.Join("triage", video.Filename)
}
c.serveFromStorage(g, store, path)
}
// VideoThumb godoc
//
// @Summary Get video thumbnail image
// @Tags video
// @Param id path string true "Video ID"
// @Success 200 file bytes
// @Failure 404
// @Router /video/{id}/thumb [get]
func (c *Controller) VideoThumb(g *gin.Context) {
id := g.Param("id")
video := &model.Video{ID: id}
result := c.datastore.First(video)
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
if !video.Thumbnail {
g.JSON(404, gin.H{})
return
}
store, err := c.storageResolver.Storage(c.videoLibraryID(video))
if err != nil {
c.logger.Error().Err(err).Str("video_id", video.ID).Str("library_id", c.videoLibraryID(video)).Msg("error resolving storage")
g.JSON(500, gin.H{"error": err.Error()})
return
}
c.serveFromStorage(g, store, video.ThumbnailRelativePath())
}
// VideoThumbXS godoc
//
// @Summary Get video extra-small thumbnail
// @Tags video
// @Param id path string true "Video ID"
// @Success 200 file bytes
// @Failure 404
// @Router /video/{id}/thumb_xs [get]
func (c *Controller) VideoThumbXS(g *gin.Context) {
id := g.Param("id")
video := &model.Video{ID: id}
result := c.datastore.First(video)
if result.RowsAffected < 1 {
g.JSON(404, gin.H{})
return
}
if !video.ThumbnailMini {
g.Redirect(http.StatusFound, VIDEO_THUMB_NOT_GENERATED)
return
}
store, err := c.storageResolver.Storage(c.videoLibraryID(video))
if err != nil {
g.JSON(500, gin.H{"error": err.Error()})
return
}
c.serveFromStorage(g, store, video.ThumbnailXSRelativePath())
}
package controller
import (
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/model"
)
// VideoViewIncrement godoc
//
// @Summary Increment view count for video
// @Tags video
// @Param id path string true "Video ID"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Router /video/{id}/count-view [post]
func (c *Controller) VideoViewIncrement(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 (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/controller"
"github.com/zobtube/zobtube/internal/model"
)
func UserIsAdmin(c controller.AbstractController) gin.HandlerFunc {
return func(g *gin.Context) {
user := g.MustGet("user").(*model.User)
if !user.Admin {
if strings.HasPrefix(g.Request.URL.Path, "/api") {
g.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
g.Abort()
return
}
g.Redirect(http.StatusTemporaryRedirect, "/api/error/unauthorized")
g.Abort()
return
}
g.Next()
}
}
package http
import (
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/zobtube/zobtube/internal/controller"
"github.com/zobtube/zobtube/internal/model"
)
const cookieName = "zt_auth"
func authRedirectURL(g *gin.Context) string {
nextVal := g.Request.URL.Path
if g.Request.URL.RawQuery != "" {
nextVal += "?" + g.Request.URL.RawQuery
}
return "/auth?next=" + url.QueryEscape(nextVal)
}
func apiUnauthorized(g *gin.Context) {
g.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
g.Abort()
}
// tryCookieAuth attempts to authenticate via session cookie. Returns the user if successful, nil otherwise.
func tryCookieAuth(g *gin.Context, c controller.AbstractController) *model.User {
cookie, err := g.Cookie(cookieName)
if err != nil {
return nil
}
session := &model.UserSession{ID: cookie}
result := c.GetSession(session)
if result.RowsAffected < 1 {
return nil
}
if session.ValidUntil.Before(time.Now()) {
return nil
}
if session.UserID == nil || *session.UserID == "" {
return nil
}
user := &model.User{ID: *session.UserID}
result = c.GetUser(user)
if result.RowsAffected < 1 {
return nil
}
return user
}
func UserIsAuthenticated(c controller.AbstractController) gin.HandlerFunc {
return func(g *gin.Context) {
isAPI := strings.HasPrefix(g.Request.URL.Path, "/api")
if !c.AuthenticationEnabled() {
// get user
user := &model.User{}
result := c.GetFirstUser(user)
if result.RowsAffected < 1 {
if isAPI {
g.JSON(http.StatusInternalServerError, gin.H{"error": "no user"})
g.Abort()
return
}
g.Redirect(http.StatusFound, "/")
g.Abort()
return
}
// set meta in context
g.Set("user", user)
// all good, exiting middleware
g.Next()
return
}
user := tryCookieAuth(g, c)
if user != nil {
g.Set("user", user)
g.Next()
return
}
// For API requests, try Bearer token
if isAPI {
authz := g.GetHeader("Authorization")
if strings.HasPrefix(authz, "Bearer ") {
token := strings.TrimSpace(authz[7:])
if token != "" {
if bearerUser, ok := c.ResolveUserByApiTokenHash(token); ok {
g.Set("user", bearerUser)
g.Next()
return
}
}
}
}
if isAPI {
apiUnauthorized(g)
return
}
g.Redirect(http.StatusFound, authRedirectURL(g))
g.Abort()
}
}
package http
import (
"net/http"
"github.com/gin-gonic/gin"
swaggerfiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
swagv2 "github.com/swaggo/swag/v2"
"github.com/zobtube/zobtube/internal/controller"
_ "github.com/zobtube/zobtube/internal/swagger"
)
// main http server setup
func (server *Server) ControllerSetupDefault(c *controller.AbstractController) {
server.setupRoutes(*c)
// both next settings are needed for filepath used above
server.Router.UseRawPath = true
server.Router.UnescapePathValues = false
server.Router.RemoveExtraSlash = false
// serve swagger documentation from embedded internal/swagger docs (swag v2).
// gin-swagger uses swag v1, so we serve doc.json ourselves via swag v2 and point the UI at it.
// /swagger redirects to /swagger/ so relative URLs (./swagger-ui.css) resolve correctly.
// Register /swagger/*any first to avoid Gin route conflict; handle doc.json inside the handler.
swaggerHandler := ginSwagger.WrapHandler(swaggerfiles.Handler, ginSwagger.URL("/swagger/doc.json"))
server.Router.GET("/swagger", func(g *gin.Context) {
g.Redirect(http.StatusFound, "/swagger/")
})
server.Router.GET("/swagger/*any", func(g *gin.Context) {
any := g.Param("any")
if any == "/doc.json" {
doc, err := swagv2.ReadDoc(swagv2.Name)
if err != nil {
g.AbortWithStatus(http.StatusInternalServerError)
return
}
g.Data(http.StatusOK, "application/json", []byte(doc))
return
}
if any == "/" || any == "" {
g.Request.URL.Path = "/swagger/index.html"
g.Request.RequestURI = "/swagger/index.html"
}
swaggerHandler(g)
})
}
// failsafe http server setup - unexpected error
func (server *Server) ControllerSetupFailsafeError(c controller.AbstractController, faultyError error) {
// server is not healthy
server.healthy = false
// redirect everything on '/'
server.Router.NoRoute(func(c *gin.Context) {
c.Redirect(http.StatusFound, "/")
})
// server the error page
server.Router.GET("", func(g *gin.Context) {
g.HTML(http.StatusOK, "web/page/failsafe/error.html", gin.H{
"Error": faultyError,
})
})
}
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 (
"github.com/gin-gonic/gin"
)
func (s *Server) livenessProbe(c *gin.Context) {
if s.healthy {
c.String(200, "alive")
} else {
c.String(500, "ko")
}
}
package http
import (
"github.com/zobtube/zobtube/internal/controller"
)
func (s *Server) setupRoutes(c controller.AbstractController) {
// Auth (no auth required): SPA at /auth shows login form; POST /auth/login for login
auth := s.Router.Group("/auth")
auth.GET("", c.SPAApp)
auth.POST("/login", c.AuthLogin)
auth.GET("/logout", c.AuthLogoutRedirect)
// SPA shell - single endpoint for the app
s.Router.GET("/", c.SPAApp)
// Bootstrap (unauthenticated) - returns auth_enabled and user for SPA init
s.Router.GET("/api/bootstrap", c.Bootstrap)
authGroup := s.Router.Group("")
authGroup.Use(UserIsAuthenticated(c))
admGroup := s.Router.Group("")
admGroup.Use(UserIsAuthenticated(c))
admGroup.Use(UserIsAdmin(c))
// Logout (authenticated)
authGroup.POST("/api/auth/logout", c.AuthLogout)
authGroup.GET("/api/auth/me", c.AuthMe)
// Home
authGroup.GET("/api/home", c.Home)
// Actors - list and get for all auth users; create/delete/mutate for admin
authGroup.GET("/api/actor", c.ActorList)
authGroup.GET("/api/actor/:id", c.ActorGet)
authGroup.GET("/api/actor/:id/thumb", c.ActorThumb)
actorGroup := admGroup.Group("/api/actor")
{
actorGroup.POST("/", c.ActorNew)
actorGroup.DELETE("/:id", c.ActorDelete)
actorGroup.POST("/:id/rename", c.ActorRename)
actorGroup.POST("/:id/description", c.ActorDescription)
actorGroup.POST("/:id/merge", c.ActorMerge)
// providers
actorGroup.GET("/:id/provider/:provider_slug", c.ActorProviderSearch)
// links
actorGroup.DELETE("/link/:id", c.ActorLinkThumbDelete)
actorGroup.GET("/link/:id/thumb", c.ActorLinkThumbGet)
actorGroup.POST("/:id/link", c.ActorLinkCreate)
// thumb
actorGroup.POST("/:id/thumb", c.ActorUploadThumb)
// alias
actorGroup.POST("/:id/alias", c.ActorAliasCreate)
actorGroup.DELETE("/alias/:id", c.ActorAliasRemove)
// categories
actorGroup.PUT("/:id/category/:category_id", c.ActorCategories)
actorGroup.DELETE("/:id/category/:category_id", c.ActorCategories)
}
// Categories
authGroup.GET("/api/category", c.CategoryList)
authGroup.GET("/api/category/:id", c.CategorySubGet)
authGroup.GET("/api/category-sub/:id/thumb", c.CategorySubThumb)
admGroup.POST("/api/category", c.CategoryAdd)
admGroup.DELETE("/api/category/:id", c.CategoryDelete)
admGroup.POST("/api/category-sub/:id/thumb", c.CategorySubThumbSet)
admGroup.DELETE("/api/category-sub/:id/thumb", c.CategorySubThumbRemove)
admGroup.POST("/api/category-sub", c.CategorySubAdd)
admGroup.POST("/api/category-sub/:id/rename", c.CategorySubRename)
// Channels
authGroup.GET("/api/channel/map", c.ChannelMap)
authGroup.GET("/api/channel", c.ChannelList)
authGroup.GET("/api/channel/:id", c.ChannelGet)
authGroup.GET("/api/channel/:id/thumb", c.ChannelThumb)
admGroup.POST("/api/channel", c.ChannelCreate)
admGroup.PUT("/api/channel/:id", c.ChannelUpdate)
// Videos
authGroup.GET("/api/clip", c.ClipList)
authGroup.GET("/api/clip/:id", c.ClipView)
authGroup.GET("/api/movie", c.MovieList)
authGroup.GET("/api/video", c.VideoList)
authGroup.GET("/api/video/:id", c.VideoView)
admGroup.GET("/api/video/:id/edit", c.VideoEdit)
authGroup.GET("/api/video/:id/summary", c.VideoGet)
authGroup.GET("/api/video/:id/stream", c.VideoStream)
authGroup.GET("/api/video/:id/thumb", c.VideoThumb)
authGroup.GET("/api/video/:id/thumb_xs", c.VideoThumbXS)
videoGroup := admGroup.Group("/api/video")
{
videoGroup.POST("", c.VideoCreate)
videoGroup.HEAD("/:id", c.VideoStreamInfo)
videoGroup.DELETE("/:id", c.VideoDelete)
videoGroup.POST("/:id/upload", c.VideoUpload)
videoGroup.POST("/:id/thumb", c.VideoUploadThumb)
videoGroup.POST("/:id/migrate", c.VideoMigrate)
videoGroup.PUT("/:id/actor/:actor_id", c.VideoActors)
videoGroup.DELETE("/:id/actor/:actor_id", c.VideoActors)
videoGroup.PUT("/:id/category/:category_id", c.VideoCategories)
videoGroup.DELETE("/:id/category/:category_id", c.VideoCategories)
videoGroup.POST("/:id/generate-thumbnail/:timing", c.VideoGenerateThumbnail)
videoGroup.POST("/:id/rename", c.VideoRename)
videoGroup.POST("/:id/count-view", c.VideoViewIncrement)
videoGroup.POST("/:id/channel", c.VideoEditChannel)
videoGroup.POST("/:id/library", c.VideoEditLibrary)
}
// Uploads
uploadGroup := admGroup.Group("/api/upload")
{
uploadGroup.POST("/import", c.UploadImport)
uploadGroup.GET("/preview/:filepath", c.UploadPreview)
uploadGroup.POST("/triage/folder", c.UploadTriageFolder)
uploadGroup.POST("/triage/file", c.UploadTriageFile)
uploadGroup.POST("/file", c.UploadFile)
uploadGroup.DELETE("/file", c.UploadDeleteFile)
uploadGroup.POST("/folder", c.UploadFolderCreate)
uploadGroup.POST("/triage/mass-action", c.UploadMassImport)
uploadGroup.DELETE("/triage/mass-action", c.UploadMassDelete)
}
// Adm
admGroup.GET("/api/adm", c.AdmHome)
admGroup.GET("/api/adm/video", c.AdmVideoList)
admGroup.GET("/api/adm/actor", c.AdmActorList)
admGroup.GET("/api/adm/channel", c.AdmChannelList)
admGroup.GET("/api/adm/category", c.AdmCategory)
admGroup.GET("/api/adm/config/auth", c.AdmConfigAuth)
admGroup.GET("/api/adm/config/auth/:action", c.AdmConfigAuthUpdate)
admGroup.GET("/api/adm/config/provider", c.AdmConfigProvider)
admGroup.GET("/api/adm/config/provider/:id/switch", c.AdmConfigProviderSwitch)
admGroup.GET("/api/adm/config/offline", c.AdmConfigOfflineMode)
admGroup.GET("/api/adm/config/offline/:action", c.AdmConfigOfflineModeUpdate)
admGroup.GET("/api/adm/task/home", c.AdmTaskHome)
admGroup.GET("/api/adm/task", c.AdmTaskList)
admGroup.GET("/api/adm/task/:id", c.AdmTaskView)
admGroup.POST("/api/adm/task/:id/retry", c.AdmTaskRetry)
admGroup.GET("/api/adm/user", c.AdmUserList)
admGroup.POST("/api/adm/user", c.AdmUserNew)
admGroup.DELETE("/api/adm/user/:id", c.AdmUserDelete)
admGroup.GET("/api/adm/tokens", c.AdmTokenList)
admGroup.DELETE("/api/adm/tokens/:id", c.AdmTokenDelete)
admGroup.GET("/api/adm/libraries", c.AdmLibraryList)
admGroup.POST("/api/adm/libraries", c.AdmLibraryCreate)
admGroup.PUT("/api/adm/libraries/:id", c.AdmLibraryUpdate)
admGroup.DELETE("/api/adm/libraries/:id", c.AdmLibraryDelete)
// Profile
authGroup.GET("/api/profile", c.ProfileView)
authGroup.POST("/api/profile/password", c.ProfileChangePassword)
authGroup.GET("/api/profile/tokens", c.ProfileTokenList)
authGroup.POST("/api/profile/tokens", c.ProfileTokenCreate)
authGroup.DELETE("/api/profile/tokens/:id", c.ProfileTokenDelete)
// Error
authGroup.Any("/api/error/unauthorized", c.ErrUnauthorized)
// NoRoute: serve SPA for GET (client-side routes) or JSON 404
s.Router.NoRoute(c.NoRouteOrSPA)
}
package http
import (
"context"
"net/http"
"os"
"os/exec"
"time"
)
func (s *Server) Start(bindAddress string) error {
s.Logger.Info().Str("kind", "system").Str("bind", bindAddress).Msg("http server binding")
// #nosec G112
s.Server = &http.Server{
Addr: bindAddress,
Handler: s.Router.Handler(),
}
err := s.Server.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
s.Logger.Error().Err(err).Msg("cannot start http server")
return err
}
return nil
}
func (s *Server) WaitForStopSignal(c <-chan int) {
mode := <-c
s.Logger.Warn().Str("kind", "system").Int("signal", mode).Msg("http server signal received")
// 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:
s.Logger.Warn().Str("kind", "system").Msg("server shutdown requested")
case 2:
s.Logger.Warn().Str("kind", "system").Msg("server restart requested")
// #nosec G204
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"
"time"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog"
)
type Server struct {
Server *http.Server
Router *gin.Engine
FS *embed.FS
Logger *zerolog.Logger
authentication bool
healthy bool
}
func New(embedfs *embed.FS, ginDebug bool, logger *zerolog.Logger) *Server {
// only show gin debugging if ginDebug is set to true
if ginDebug {
logger.Warn().Msg("gin debugging mode activated")
} else {
gin.SetMode(gin.ReleaseMode)
}
// create common server
server := &Server{
Router: gin.New(),
FS: embedfs,
Logger: logger,
authentication: false,
healthy: true,
}
// add recovery middleware
server.Router.Use(gin.Recovery())
// setup logger
server.Router.Use(func(c *gin.Context) {
start := time.Now()
c.Next()
end := time.Now()
latency := end.Sub(start)
logger.Info().
Str("method", c.Request.Method).
Str("path", c.Request.RequestURI).
Int("status", c.Writer.Status()).
Str("ip", c.ClientIP()).
Dur("latency", latency).
Msg("http request")
})
// template error handling
server.Router.Use(func(c *gin.Context) {
c.Next()
err := c.Errors.Last()
if err != nil {
logger.Error().Err(err).Msg("html template rendering failed")
}
})
// load templates (web/page/** matches files in page and in subdirs e.g. app.html, actor/list.html)
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", server.livenessProbe)
return server
}
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:2;"`
Aliases []ActorAlias
Links []ActorLink
Categories []CategorySub `gorm:"many2many:actor_categories;"`
Description string
}
// 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",
"tw": "trans-women",
}
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("/api/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 (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type ApiToken struct {
ID string `gorm:"type:uuid;primary_key"`
UserID string `gorm:"type:uuid;index;not null"`
Name string `gorm:"not null"`
TokenHash string `gorm:"uniqueIndex;not null"`
CreatedAt time.Time `gorm:"not null"`
}
func (a *ApiToken) BeforeCreate(tx *gorm.DB) error {
if a.ID == "" {
a.ID = uuid.NewString()
}
return nil
}
package model
import (
"gorm.io/gorm"
)
// EnsureDefaultLibrary creates a default filesystem library from mediaPath if no libraries exist.
// Returns the default library ID (existing or newly created) and any error.
func EnsureDefaultLibrary(db *gorm.DB, mediaPath string) (string, error) {
var count int64
err := db.Model(&Library{}).Count(&count).Error
if err != nil {
return "", err
}
if count > 0 {
var def Library
err = db.Where("is_default = ?", true).First(&def).Error
if err != nil {
err = db.First(&def).Error
if err != nil {
return "", err
}
}
return def.ID, nil
}
// Use fixed UUID so migration default (videos.library_id) points to this library without backfill.
const defaultLibraryUUID = "00000000-0000-0000-0000-000000000000"
lib := Library{
ID: defaultLibraryUUID,
Name: "Default",
Type: LibraryTypeFilesystem,
IsDefault: true,
Config: LibraryConfig{
Filesystem: &LibraryConfigFilesystem{Path: mediaPath},
},
}
if err := db.Create(&lib).Error; err != nil {
return "", err
}
return lib.ID, nil
}
// BackfillVideoLibraryID sets library_id to defaultLibraryID for all videos where library_id is null.
func BackfillVideoLibraryID(db *gorm.DB, defaultLibraryID string) error {
return db.Model(&Video{}).Where("library_id IS NULL").Updates(map[string]any{"library_id": defaultLibraryID}).Error
}
package model
import (
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type CategorySub struct {
ID string `gorm:"type:uuid;primary_key"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Name string
Category string `gorm:"type:uuid"`
Thumbnail bool
Videos []*Video `gorm:"many2many:video_categories;"`
Actors []*Actor `gorm:"many2many:actor_categories;"`
}
// UUID pre-hook
func (c *CategorySub) 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 *CategorySub) URLThumb() string {
return fmt.Sprintf("/api/category-sub/%s/thumb", c.ID)
}
package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Category struct {
ID string `gorm:"type:uuid;primary_key"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Name string
Priority int
Sub []CategorySub `gorm:"foreignKey:Category;references:ID"`
}
// UUID pre-hook
func (c *Category) 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
}
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("/api/channel/%s/thumb", c.ID)
}
func (c *Channel) URLAdmEdit() string {
return fmt.Sprintf("/channel/%s/edit", c.ID)
}
package model
import (
"database/sql/driver"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// LibraryType is the storage backend type for a library.
type LibraryType string
const (
LibraryTypeFilesystem LibraryType = "filesystem"
LibraryTypeS3 LibraryType = "s3"
)
// LibraryConfigFilesystem is the config for a filesystem library.
type LibraryConfigFilesystem struct {
Path string `json:"path"`
}
// LibraryConfigS3 is the config for an S3 library.
// AccessKeyID and SecretAccessKey are optional; if not set, the default credential chain is used (env AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, or IAM).
type LibraryConfigS3 struct {
Bucket string `json:"bucket"`
Region string `json:"region,omitempty"`
Prefix string `json:"prefix,omitempty"`
Endpoint string `json:"endpoint,omitempty"` // for Minio etc.
AccessKeyID string `json:"access_key_id,omitempty"`
SecretAccessKey string `json:"secret_access_key,omitempty"`
}
// LibraryConfig holds either filesystem or S3 config as JSON.
type LibraryConfig struct {
Filesystem *LibraryConfigFilesystem `json:"filesystem,omitempty"`
S3 *LibraryConfigS3 `json:"s3,omitempty"`
}
// Value implements driver.Valuer for GORM.
func (c LibraryConfig) Value() (driver.Value, error) {
if c.Filesystem == nil && c.S3 == nil {
return nil, nil
}
return json.Marshal(c)
}
// Scan implements sql.Scanner for GORM.
func (c *LibraryConfig) Scan(value interface{}) error {
if value == nil {
return nil
}
b, ok := value.([]byte)
if !ok {
return errors.New("library config: invalid type")
}
return json.Unmarshal(b, c)
}
// Library is a named media storage target (filesystem or S3).
type Library struct {
ID string `gorm:"type:uuid;primary_key"`
CreatedAt time.Time
UpdatedAt time.Time
Name string `gorm:"size:255;not null"`
Type LibraryType `gorm:"size:32;not null"`
Config LibraryConfig `gorm:"type:text"` // JSON
IsDefault bool `gorm:"default:false"` // one library is used for actor/channel/category assets
}
func (l *Library) BeforeCreate(tx *gorm.DB) error {
if l.ID == "" {
l.ID = uuid.NewString()
}
return nil
}
package model
import (
"errors"
"fmt"
"strings"
"github.com/glebarez/sqlite"
"github.com/rs/zerolog/log"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/zobtube/zobtube/internal/config"
)
func isSQLite(cfg *config.Config) bool {
return strings.EqualFold(cfg.DB.Driver, "sqlite")
}
var modelToMigrate = []any{
Actor{},
ActorAlias{},
ActorLink{},
Category{},
CategorySub{},
Channel{},
Configuration{},
Library{},
Provider{},
Video{},
VideoView{},
Task{},
User{},
UserSession{},
ApiToken{},
}
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
}
if isSQLite(cfg) {
if err := migrateSQLiteVideoLibraryID(db); err != nil {
return nil, err
}
}
// migrate all known models
for _, m := range modelToMigrate {
err = db.AutoMigrate(&m)
if err != nil {
log.Error().Err(err).Msg("failed to migrate model")
fmt.Printf("failed to migrate model: model=%T error=%v\n", m, err)
if isSQLite(cfg) && isSQLiteNotNullDefaultNullError(err) {
// GORM tried to add library_id without default; add it with default then retry
if retryErr := migrateSQLiteVideoLibraryID(db); retryErr != nil {
return nil, retryErr
}
err = db.AutoMigrate(&m)
}
if err != nil {
return nil, err
}
}
}
return db, nil
}
// migrateSQLiteVideoLibraryID adds videos.library_id with DEFAULT for SQLite so that
// adding the column to a non-empty table succeeds. Run before AutoMigrate so
// GORM does not try to add the column without a default.
func migrateSQLiteVideoLibraryID(db *gorm.DB) error {
var count int64
if err := db.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='videos'").Scan(&count).Error; err != nil {
return err
}
if count == 0 {
return nil
}
const defaultLibraryUUID = "00000000-0000-0000-0000-000000000000"
// Use literal default so SQLite clearly has a non-null default when adding the column.
err := db.Exec("ALTER TABLE videos ADD COLUMN library_id TEXT DEFAULT '" + defaultLibraryUUID + "'").Error
if err != nil {
if strings.Contains(err.Error(), "duplicate column name") {
return nil
}
return err
}
_ = db.Exec("CREATE INDEX IF NOT EXISTS idx_videos_library_id ON videos(library_id)").Error
return nil
}
func isSQLiteNotNullDefaultNullError(err error) bool {
return err != nil && strings.Contains(err.Error(), "Cannot add a NOT NULL column with default value NULL")
}
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
LibraryID *string `gorm:"type:uuid;index;default:00000000-0000-0000-0000-000000000000"` // nil = legacy, backfilled to default library
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"`
Categories []CategorySub `gorm:"many2many:video_categories;"`
}
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 {
if v.Type == "c" {
return fmt.Sprintf("/clip/%s", v.ID)
}
return fmt.Sprintf("/video/%s", v.ID)
}
func (v *Video) URLThumb() string {
return fmt.Sprintf("/api/video/%s/thumb", v.ID)
}
func (v *Video) URLThumbXS() string {
return fmt.Sprintf("/api/video/%s/thumb_xs", v.ID)
}
func (v *Video) URLStream() string {
return fmt.Sprintf("/api/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)
}
return fmt.Sprintf("%02d:%02d", m, s)
}
func (v *Video) NiceDurationShort() 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("%2dh%02d", h, m)
}
if m > 0 {
return fmt.Sprintf("%2d min", m)
}
return fmt.Sprintf("%2d sec", 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)
}
// FolderRelativePathForType returns the folder path for the given type (used when migrating type).
func (v *Video) FolderRelativePathForType(typeStr string) string {
return filepath.Join(videoFileTypeToPath[typeStr], 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) CapabilitySearchActor() bool {
return true
}
func (p *Babepedia) CapabilityScrapePicture() bool {
return true
}
func (p *Babepedia) ActorSearch(offlineMode bool, actorName string) (url string, err error) {
if offlineMode {
return url, ErrOfflineMode
}
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(offlineMode bool, actorName, url string) (thumb []byte, err error) {
if offlineMode {
return thumb, ErrOfflineMode
}
client := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
_actorName := strings.Split(url, "/")[len(strings.Split(url, "/"))-1]
if _actorName == "" {
return thumb, errors.New("actor name not found in url")
}
_url := "https://www.babepedia.com/pics/" + 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, errors.New("thumb not found at given url")
}
// 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) CapabilitySearchActor() bool {
return true
}
func (p *BabesDirectory) CapabilityScrapePicture() bool {
return true
}
func babesDirectoryGet(client *http.Client, url string) (*http.Response, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT x.y; Win64; x64; rv:10.0) Gecko/20100101 Firefox/10.0")
return client.Do(req)
}
func (p *BabesDirectory) ActorSearch(offlineMode bool, actorName string) (url string, err error) {
if offlineMode {
return url, ErrOfflineMode
}
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), " ", "-")
resp, err := babesDirectoryGet(client, url)
if err != nil {
return url, err
}
if resp.StatusCode == 200 {
return url, nil
}
url += "-pornstar"
resp, err = babesDirectoryGet(client, url)
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(offlineMode bool, actorName, url string) (thumb []byte, err error) {
if offlineMode {
return thumb, ErrOfflineMode
}
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := babesDirectoryGet(client, url)
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
resp, err = babesDirectoryGet(client, url)
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) CapabilitySearchActor() bool {
return true
}
func (p *Boobpedia) CapabilityScrapePicture() bool {
return true
}
func boobpediaGet(client *http.Client, url string) (*http.Response, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT x.y; Win64; x64; rv:10.0) Gecko/20100101 Firefox/10.0")
return client.Do(req)
}
func (p *Boobpedia) ActorSearch(offlineMode bool, actorName string) (url string, err error) {
if offlineMode {
return url, ErrOfflineMode
}
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), " ", "_")
resp, err := boobpediaGet(client, url)
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(offlineMode bool, actorName, url string) (thumb []byte, err error) {
if offlineMode {
return thumb, ErrOfflineMode
}
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := boobpediaGet(client, url)
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(`class=\"mw-file-description\"[\s\w="<>]* 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]
// retrieve thumb
url = "https://www.boobpedia.com/" + url
resp, err = boobpediaGet(client, url)
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 (
"crypto/tls"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
type IAFD struct {
client *http.Client
}
func (p *IAFD) SlugGet() string {
return "iafd"
}
func (p *IAFD) NiceName() string {
return "IAFD"
}
func (p *IAFD) CapabilitySearchActor() bool {
return true
}
func (p *IAFD) CapabilityScrapePicture() bool {
return true
}
func (p *IAFD) IAFDGet(url string) (*http.Response, error) {
if p.client == nil {
p.client = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
DisableCompression: true,
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS13,
},
},
}
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0")
req.Header.Add("Accept", "*/*")
resp, err := p.client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("provider did not find actor, got status code %d", resp.StatusCode)
}
return resp, nil
}
func (p *IAFD) ActorSearch(offlineMode bool, actorName string) (url string, err error) {
if offlineMode {
return url, ErrOfflineMode
}
// search actor
caser := cases.Title(language.English)
url = "https://www.iafd.com/results.asp?searchtype=comprehensive&searchstring=" + strings.ReplaceAll(caser.String(actorName), " ", "+")
resp, err := p.IAFDGet(url)
if err != nil {
return url, err
}
// process page
pageData, err := io.ReadAll(resp.Body)
if err != nil {
return url, err
}
re := regexp.MustCompile(`<tr><td><a href="(\/person.rme\/[^"]*)`)
matches := re.FindAllStringSubmatch(string(pageData), -1)
if len(matches) == 0 {
return url, errors.New("provider did not find actor")
}
if len(matches) > 1 {
return url, errors.New("provider matches more than one actor")
}
return "https://www.iafd.com" + matches[0][1], nil
}
func (p *IAFD) ActorGetThumb(offlineMode bool, actorName, url string) (thumb []byte, err error) {
if offlineMode {
return thumb, ErrOfflineMode
}
resp, err := p.IAFDGet(url)
if err != nil {
return thumb, err
}
// process page
pageData, err := io.ReadAll(resp.Body)
if err != nil {
return thumb, err
}
divRegex := regexp.MustCompile(`<div id="headshot"><img(.*src="([^\"]*))`)
thumbURLMatches := divRegex.FindAllStringSubmatch(string(pageData), -1)
url = ""
for _, match := range thumbURLMatches {
if len(match) > 2 {
url = match[2]
}
}
if url == "" {
// definitely not found
return thumb, errors.New("provider did not return a thumbnail")
}
// retrieve thumb
resp, err = p.IAFDGet(url)
if err != nil {
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 Pornhub struct{}
func (p *Pornhub) SlugGet() string {
return "pornhub"
}
func (p *Pornhub) NiceName() string {
return "PornHub"
}
func (p *Pornhub) CapabilitySearchActor() bool {
return true
}
func (p *Pornhub) CapabilityScrapePicture() bool {
return true
}
func pornhubGet(client *http.Client, url string) (*http.Response, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Add("Cookie", "accessAgeDisclaimerPH=1")
return client.Do(req)
}
func (p *Pornhub) ActorSearch(offlineMode bool, actorName string) (url string, err error) {
if offlineMode {
return url, ErrOfflineMode
}
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), " ", "-")
resp, err := pornhubGet(client, url)
if err != nil {
return url, err
}
if resp.StatusCode == 200 {
return url, nil
}
url = "https://www.pornhub.com/model/" + strings.ReplaceAll(strings.ToLower(actorName), " ", "-")
resp, err = pornhubGet(client, url)
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(offlineMode bool, actor_name, url string) (thumb []byte, err error) {
if offlineMode {
return thumb, ErrOfflineMode
}
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := pornhubGet(client, url)
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
resp, err = pornhubGet(client, url)
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, 1000)
}
func (r *Runner) Start(cfg *config.Config, db *gorm.DB, storageResolver common.StorageResolver) {
r.ctx = &common.Context{
DB: db,
Config: cfg,
StorageResolver: storageResolver,
}
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 storage
import (
"io"
"os"
"path/filepath"
)
// Filesystem implements Storage using a local directory root.
type Filesystem struct {
Root string
}
// NewFilesystem returns a Storage that uses the given root directory.
func NewFilesystem(root string) *Filesystem {
return &Filesystem{Root: root}
}
// Open opens the file at path for reading.
func (f *Filesystem) Open(path string) (io.ReadCloser, error) {
full := filepath.Join(f.Root, path)
return os.Open(full)
}
// Create creates or overwrites the file at path.
func (f *Filesystem) Create(path string) (io.WriteCloser, error) {
full := filepath.Join(f.Root, path)
dir := filepath.Dir(full)
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, err
}
return os.Create(full)
}
// Delete removes the file at path.
func (f *Filesystem) Delete(path string) error {
full := filepath.Join(f.Root, path)
err := os.Remove(full)
if err != nil && os.IsNotExist(err) {
return nil
}
return err
}
// Exists returns true if the file at path exists.
func (f *Filesystem) Exists(path string) (bool, error) {
full := filepath.Join(f.Root, path)
_, err := os.Stat(full)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
return true, nil
}
// MkdirAll ensures the directory at path exists.
func (f *Filesystem) MkdirAll(path string) error {
full := filepath.Join(f.Root, path)
return os.MkdirAll(full, 0o750)
}
// List returns entries under prefix. prefix is relative to Root.
func (f *Filesystem) List(prefix string) ([]Entry, error) {
full := filepath.Join(f.Root, prefix)
entries, err := os.ReadDir(full)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
out := make([]Entry, 0, len(entries))
for _, e := range entries {
info, err := e.Info()
if err != nil {
continue
}
out = append(out, Entry{
Name: e.Name(),
Size: info.Size(),
ModTime: info.ModTime(),
IsDir: e.IsDir(),
})
}
return out, nil
}
// FullPath returns the absolute path for a relative path (for use with gin.Context.File when serving).
// Only valid for filesystem storage.
func (f *Filesystem) FullPath(path string) string {
return filepath.Join(f.Root, path)
}
// Stat returns FileInfo for the path if the storage is filesystem-based.
// Returns nil if not available (e.g. S3). Used by controllers that serve via g.File().
func (f *Filesystem) Stat(path string) (os.FileInfo, error) {
full := filepath.Join(f.Root, path)
return os.Stat(full)
}
package storage
import (
"io"
"os"
"path/filepath"
)
// LocalPathForRead returns a local filesystem path that can be used to read the object (e.g. by ffprobe/ffmpeg).
// For filesystem storage this is the actual path; for S3 etc. the object is copied to a temp file.
// The caller must call the returned cleanup function when done.
func LocalPathForRead(store Storage, path string) (localPath string, cleanup func(), err error) {
if store == nil {
return "", nil, os.ErrInvalid
}
if fs, ok := store.(*Filesystem); ok {
return fs.FullPath(path), func() {}, nil
}
rc, err := store.Open(path)
if err != nil {
return "", nil, err
}
defer rc.Close()
f, err := os.CreateTemp("", "zt-*"+filepath.Base(path))
if err != nil {
return "", nil, err
}
_, err = io.Copy(f, rc)
if err != nil {
f.Close()
os.Remove(f.Name())
return "", nil, err
}
if err := f.Close(); err != nil {
os.Remove(f.Name())
return "", nil, err
}
return f.Name(), func() { os.Remove(f.Name()) }, nil
}
package storage
import (
"context"
"errors"
"sync"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"gorm.io/gorm"
"github.com/zobtube/zobtube/internal/model"
)
// Resolver returns Storage for a library by ID (cached).
type Resolver struct {
db *gorm.DB
cache sync.Map // libraryID -> Storage
}
// NewResolver returns a new storage resolver.
func NewResolver(db *gorm.DB) *Resolver {
return &Resolver{db: db}
}
// Storage returns the Storage for the given library ID. Results are cached.
func (r *Resolver) Storage(libraryID string) (Storage, error) {
if v, ok := r.cache.Load(libraryID); ok {
return v.(Storage), nil
}
store, err := r.load(libraryID)
if err != nil {
return nil, err
}
r.cache.Store(libraryID, store)
return store, nil
}
// Invalidate removes the cached Storage for the given library (e.g. after config change).
func (r *Resolver) Invalidate(libraryID string) {
r.cache.Delete(libraryID)
}
func (r *Resolver) load(libraryID string) (Storage, error) {
var lib model.Library
err := r.db.First(&lib, "id = ?", libraryID).Error
if err != nil {
return nil, err
}
switch lib.Type {
case model.LibraryTypeFilesystem:
path := ""
if lib.Config.Filesystem != nil {
path = lib.Config.Filesystem.Path
}
return NewFilesystem(path), nil
case model.LibraryTypeS3:
if lib.Config.S3 == nil {
return nil, ErrS3ConfigMissing
}
cfg := lib.Config.S3
var creds *struct{ AccessKey, SecretKey string }
if cfg.AccessKeyID != "" && cfg.SecretAccessKey != "" {
creds = &struct{ AccessKey, SecretKey string }{AccessKey: cfg.AccessKeyID, SecretKey: cfg.SecretAccessKey}
}
client, err := newS3Client(cfg.Region, cfg.Endpoint, creds)
if err != nil {
return nil, err
}
return NewS3(client, cfg.Bucket, cfg.Prefix), nil
default:
return nil, ErrUnknownLibraryType
}
}
var (
ErrS3ConfigMissing = errors.New("library: s3 config missing")
ErrUnknownLibraryType = errors.New("library: unknown type")
)
// newS3Client builds an S3 client. Credentials come from env (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) or IAM.
// endpoint is optional (e.g. for Minio); creds are optional (for static Minio credentials).
func newS3Client(region, endpoint string, creds *struct{ AccessKey, SecretKey string }) (*s3.Client, error) {
opts := []func(*config.LoadOptions) error{
config.WithRegion(region),
}
if creds != nil && creds.AccessKey != "" && creds.SecretKey != "" {
opts = append(opts, config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
creds.AccessKey, creds.SecretKey, "",
)))
}
cfg, err := config.LoadDefaultConfig(context.Background(), opts...)
if err != nil {
return nil, err
}
if endpoint != "" {
return s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(endpoint)
o.UsePathStyle = true
}), nil
}
return s3.NewFromConfig(cfg), nil
}
package storage
import (
"bytes"
"context"
"io"
"mime"
"path/filepath"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
// S3 implements Storage using S3-compatible object storage.
type S3 struct {
client *s3.Client
bucket string
prefix string // optional key prefix for all objects
}
// NewS3 returns a Storage that uses the given S3 client, bucket, and optional key prefix.
func NewS3(client *s3.Client, bucket, prefix string) *S3 {
return &S3{client: client, bucket: bucket, prefix: prefix}
}
// contentTypeByPath returns the Content-Type for S3 PutObject based on file extension.
// Covers common image and video types; returns empty string if unknown.
func contentTypeByPath(path string) string {
ext := strings.ToLower(filepath.Ext(path))
if ext == "" {
return ""
}
// Ensure common video/image types are recognized (some systems' mime.types omit these)
switch ext {
case ".mp4":
return "video/mp4"
case ".webm":
return "video/webm"
case ".mkv":
return "video/x-matroska"
case ".mov":
return "video/quicktime"
case ".avi":
return "video/x-msvideo"
case ".m4v":
return "video/x-m4v"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
case ".svg":
return "image/svg+xml"
case ".ico":
return "image/x-icon"
default:
return mime.TypeByExtension(ext)
}
}
func (s *S3) key(path string) string {
path = strings.TrimPrefix(path, "/")
if s.prefix == "" {
return path
}
if path == "" {
return strings.TrimSuffix(s.prefix, "/")
}
return strings.TrimSuffix(s.prefix, "/") + "/" + path
}
// Open opens the object at path for reading (streams from S3).
func (s *S3) Open(path string) (io.ReadCloser, error) {
out, err := s.client.GetObject(context.Background(), &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s.key(path)),
})
if err != nil {
return nil, err
}
return out.Body, nil
}
// s3WriteCloser buffers writes and uploads on Close.
type s3WriteCloser struct {
s3 *S3
path string
buf bytes.Buffer
closed bool
}
func (w *s3WriteCloser) Write(p []byte) (n int, err error) {
return w.buf.Write(p)
}
func (w *s3WriteCloser) Close() error {
if w.closed {
return nil
}
w.closed = true
input := &s3.PutObjectInput{
Bucket: aws.String(w.s3.bucket),
Key: aws.String(w.s3.key(w.path)),
Body: bytes.NewReader(w.buf.Bytes()),
}
if ct := contentTypeByPath(w.path); ct != "" {
input.ContentType = aws.String(ct)
}
_, err := w.s3.client.PutObject(context.Background(), input)
return err
}
// Create creates a new object at path; writes are buffered and uploaded on Close.
func (s *S3) Create(path string) (io.WriteCloser, error) {
return &s3WriteCloser{s3: s, path: path}, nil
}
// Delete removes the object at path.
func (s *S3) Delete(path string) error {
_, err := s.client.DeleteObject(context.Background(), &s3.DeleteObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s.key(path)),
})
return err
}
// Exists returns true if the object at path exists.
func (s *S3) Exists(path string) (bool, error) {
_, err := s.client.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s.key(path)),
})
if err != nil {
// Check for 404
return false, nil // treat any error as not exists for simplicity
}
return true, nil
}
// MkdirAll is a no-op for S3 (keys are flat).
func (s *S3) MkdirAll(path string) error {
return nil
}
// List returns entries under prefix. Names are relative to the given prefix.
func (s *S3) List(prefix string) ([]Entry, error) {
fullPrefix := s.key(prefix)
if fullPrefix != "" && fullPrefix[len(fullPrefix)-1] != '/' {
fullPrefix += "/"
}
var entries []Entry
paginator := s3.NewListObjectsV2Paginator(s.client, &s3.ListObjectsV2Input{
Bucket: aws.String(s.bucket),
Prefix: aws.String(fullPrefix),
})
for paginator.HasMorePages() {
page, err := paginator.NextPage(context.Background())
if err != nil {
return nil, err
}
for _, obj := range page.Contents {
key := aws.ToString(obj.Key)
name := key
if len(fullPrefix) > 0 && len(key) > len(fullPrefix) {
name = key[len(fullPrefix):]
}
modTime := time.Time{}
if obj.LastModified != nil {
modTime = *obj.LastModified
}
size := int64(0)
if obj.Size != nil {
size = *obj.Size
}
entries = append(entries, Entry{
Name: name,
Size: size,
ModTime: modTime,
IsDir: false,
})
}
}
return entries, nil
}
// PresignGet returns a presigned GET URL for the object at path, valid for expiry.
// Implements PreviewableStorage.
func (s *S3) PresignGet(ctx context.Context, path string, expiry time.Duration) (string, error) {
presigner := s3.NewPresignClient(s.client, func(po *s3.PresignOptions) {
po.Expires = expiry
})
result, err := presigner.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s.key(path)),
})
if err != nil {
return "", err
}
return result.URL, nil
}
package common
import (
"log"
"time"
"github.com/zobtube/zobtube/internal/config"
"github.com/zobtube/zobtube/internal/model"
"github.com/zobtube/zobtube/internal/storage"
"gorm.io/gorm"
)
type Parameters map[string]string
type Context struct {
DB *gorm.DB
Config *config.Config
StorageResolver StorageResolver
}
// StorageResolver resolves storage by library ID (optional in context for tasks that need it).
type StorageResolver interface {
Storage(libraryID string) (storage.Storage, error)
}
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"
"github.com/zobtube/zobtube/internal/model"
"github.com/zobtube/zobtube/internal/task/common"
)
func NewVideoDeleting() *common.Task {
return &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},
},
}
}
func deleteVideoFile(ctx *common.Context, params common.Parameters) (string, error) {
videoID := params["videoID"]
video := &model.Video{ID: videoID}
result := ctx.DB.First(video)
if result.RowsAffected < 1 {
return "video does not exist", errors.New("id not in db")
}
store, err := ctx.StorageResolver.Storage(videoLibraryID(ctx, video))
if err != nil {
return "unable to resolve storage", err
}
_ = store.Delete(video.RelativePath())
return "", nil
}
func deleteFolder(ctx *common.Context, params common.Parameters) (string, error) {
videoID := params["videoID"]
video := &model.Video{ID: videoID}
result := ctx.DB.First(video)
if result.RowsAffected < 1 {
return "video does not exist", errors.New("id not in db")
}
store, err := ctx.StorageResolver.Storage(videoLibraryID(ctx, video))
if err != nil {
return "unable to resolve storage", err
}
// Delete known files in folder (storage has no recursive delete)
_ = store.Delete(video.ThumbnailRelativePath())
_ = store.Delete(video.ThumbnailXSRelativePath())
_ = store.Delete(video.RelativePath())
return "", nil
}
func deleteInDatabase(ctx *common.Context, params common.Parameters) (string, error) {
videoID := params["videoID"]
video := &model.Video{ID: videoID}
result := ctx.DB.First(video)
if result.RowsAffected < 1 {
return "video does not exist", errors.New("id not in db")
}
if err := ctx.DB.Delete(&video).Error; err != nil {
return "unable to delete video", err
}
return "", nil
}
package video
import (
"errors"
"os/exec"
"strings"
"time"
"github.com/zobtube/zobtube/internal/model"
"github.com/zobtube/zobtube/internal/storage"
"github.com/zobtube/zobtube/internal/task/common"
)
func computeDuration(ctx *common.Context, params common.Parameters) (string, error) {
videoID := params["videoID"]
video := &model.Video{ID: videoID}
result := ctx.DB.First(video)
if result.RowsAffected < 1 {
return "video does not exist", errors.New("id not in db")
}
store, err := ctx.StorageResolver.Storage(videoLibraryID(ctx, video))
if err != nil {
return "unable to resolve storage", err
}
localPath, cleanup, err := storage.LocalPathForRead(store, video.RelativePath())
if err != nil {
return "unable to get local path for video", err
}
defer cleanup()
// #nosec G204
out, err := exec.Command(
"ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
localPath,
).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
if err := ctx.DB.Save(&video).Error; 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 (
"errors"
"io"
"path/filepath"
"github.com/zobtube/zobtube/internal/model"
"github.com/zobtube/zobtube/internal/task/common"
)
func NewVideoMoveLibrary() *common.Task {
return &common.Task{
Name: "video/move-library",
Steps: []common.Step{
{Name: "move-files", NiceName: "Copy video and thumbnails to new library", Func: moveFilesToNewLibrary},
{Name: "update-db", NiceName: "Update video library in database", Func: updateVideoLibraryID},
{Name: "delete-source", NiceName: "Remove files from source library", Func: deleteFromSourceLibrary},
},
}
}
// moveFilesToNewLibrary copies video file and thumbnails from source storage to target storage.
func moveFilesToNewLibrary(ctx *common.Context, params common.Parameters) (string, error) {
videoID := params["videoID"]
targetLibID := params["targetLibraryID"]
if targetLibID == "" {
return "targetLibraryID required", errors.New("missing targetLibraryID")
}
video := &model.Video{ID: videoID}
if ctx.DB.First(video).RowsAffected < 1 {
return "video does not exist", errors.New("video not found")
}
sourceLibID := videoLibraryID(ctx, video)
if sourceLibID == targetLibID {
return "video already in target library", errors.New("same library")
}
sourceStore, err := ctx.StorageResolver.Storage(sourceLibID)
if err != nil {
return "unable to resolve source storage", err
}
targetStore, err := ctx.StorageResolver.Storage(targetLibID)
if err != nil {
return "unable to resolve target storage", err
}
paths := []string{
video.RelativePath(),
video.ThumbnailRelativePath(),
video.ThumbnailXSRelativePath(),
}
for _, p := range paths {
ok, err := sourceStore.Exists(p)
if err != nil {
return "unable to check source file", err
}
if !ok {
continue
}
if err := targetStore.MkdirAll(filepath.Dir(p)); err != nil {
return "unable to create target folder", err
}
rc, err := sourceStore.Open(p)
if err != nil {
return "unable to open source file", err
}
wc, err := targetStore.Create(p)
if err != nil {
rc.Close()
return "unable to create target file", err
}
_, err = io.Copy(wc, rc)
rc.Close()
if err != nil {
wc.Close()
return "unable to copy file", err
}
if err := wc.Close(); err != nil {
return "unable to close target file", err
}
}
return "", nil
}
// updateVideoLibraryID sets video.LibraryID to the target library and saves.
func updateVideoLibraryID(ctx *common.Context, params common.Parameters) (string, error) {
videoID := params["videoID"]
targetLibID := params["targetLibraryID"]
video := &model.Video{ID: videoID}
if ctx.DB.First(video).RowsAffected < 1 {
return "video does not exist", errors.New("video not found")
}
video.LibraryID = &targetLibID
if err := ctx.DB.Save(video).Error; err != nil {
return "unable to update video library", err
}
return "", nil
}
// deleteFromSourceLibrary removes the video file and thumbnails from the source library.
// Video record already points to target library at this step.
func deleteFromSourceLibrary(ctx *common.Context, params common.Parameters) (string, error) {
videoID := params["videoID"]
targetLibID := params["targetLibraryID"]
video := &model.Video{ID: videoID}
if ctx.DB.First(video).RowsAffected < 1 {
return "video does not exist", errors.New("video not found")
}
// Source library is the one we moved from; video.LibraryID is now target, so we need
// to get source from params. We don't have sourceLibraryID in params. So we need to
// pass it in params from the first step - but params are fixed when task is created.
// So we have two options: (1) pass sourceLibraryID in params when creating the task
// (controller knows current library before creating task), or (2) in step 1 store
// sourceLibraryID somewhere (e.g. in task.Parameters by updating the task record).
// Option 1 is cleaner: add sourceLibraryID to the task params when creating the task.
sourceLibID := params["sourceLibraryID"]
if sourceLibID == "" {
// Backward compat: if not set, skip delete (task was created before we added this param)
return "", nil
}
if sourceLibID == targetLibID {
return "", nil
}
store, err := ctx.StorageResolver.Storage(sourceLibID)
if err != nil {
return "unable to resolve source storage", err
}
_ = store.Delete(video.RelativePath())
_ = store.Delete(video.ThumbnailRelativePath())
_ = store.Delete(video.ThumbnailXSRelativePath())
return "", nil
}
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"
"path/filepath"
"github.com/zobtube/zobtube/internal/model"
"github.com/zobtube/zobtube/internal/storage"
"github.com/zobtube/zobtube/internal/task/common"
"golang.org/x/image/draw"
)
func generateHorizontalMiniThumnail(ctx *common.Context, video *model.Video, store storage.Storage) (string, error) {
thumbPath := video.ThumbnailRelativePath()
thumbXSPath := video.ThumbnailXSRelativePath()
rc, err := store.Open(thumbPath)
if err != nil {
return "unable to open thumbnail", err
}
defer rc.Close()
src, err := jpeg.Decode(rc)
if err != nil {
return "unable to read the jpg file", err
}
targetH, targetV := 320, 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)
dst := image.NewRGBA(image.Rect(0, 0, targetH, targetV))
outerImg := gaussianBlur(originalImageRGBA, 15)
draw.NearestNeighbor.Scale(dst, dst.Bounds(), outerImg, outerImg.Bounds(), draw.Over, nil)
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)
if err := store.MkdirAll(filepath.Dir(thumbXSPath)); err != nil {
return "unable to create thumbnail folder", err
}
w, err := store.Create(thumbXSPath)
if err != nil {
return "unable to create mini thumbnail file", err
}
defer w.Close()
if err := jpeg.Encode(w, dst, &jpeg.Options{Quality: 90}); err != nil {
return "unable to encode new thumbnail", err
}
return "", nil
}
func generateSameRatioMiniThumnail(ctx *common.Context, video *model.Video, store storage.Storage) (string, error) {
thumbPath := video.ThumbnailRelativePath()
thumbXSPath := video.ThumbnailXSRelativePath()
rc, err := store.Open(thumbPath)
if err != nil {
return "unable to open thumbnail", err
}
defer rc.Close()
src, err := jpeg.Decode(rc)
if err != nil {
return "unable to read the jpg file", err
}
targetH := 320
h := src.Bounds().Dx()
v := src.Bounds().Dy()
var dst *image.RGBA
if h <= targetH {
dst = image.NewRGBA(image.Rect(0, 0, h, v))
draw.NearestNeighbor.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil)
} else {
ratio := float32(h) / float32(targetH)
v = int(float32(v) / ratio)
dst = image.NewRGBA(image.Rect(0, 0, targetH, v))
draw.NearestNeighbor.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil)
}
if err := store.MkdirAll(filepath.Dir(thumbXSPath)); err != nil {
return "unable to create thumbnail folder", err
}
w, err := store.Create(thumbXSPath)
if err != nil {
return "unable to create mini thumbnail file", err
}
defer w.Close()
if err := jpeg.Encode(w, dst, &jpeg.Options{Quality: 90}); err != nil {
return "unable to encode new thumbnail", err
}
return "", nil
}
func generateThumbnailMini(ctx *common.Context, params common.Parameters) (string, error) {
videoID := params["videoID"]
video := &model.Video{ID: videoID}
result := ctx.DB.First(video)
if result.RowsAffected < 1 {
return "video does not exist", errors.New("id not in db")
}
store, err := ctx.StorageResolver.Storage(videoLibraryID(ctx, video))
if err != nil {
return "unable to resolve storage", err
}
var errMsg string
if video.Type == "c" {
errMsg, err = generateSameRatioMiniThumnail(ctx, video, store)
} else {
errMsg, err = generateHorizontalMiniThumnail(ctx, video, store)
}
if err != nil {
return errMsg, err
}
video.ThumbnailMini = true
ctx.DB.Save(&video)
return "", nil
}
func deleteThumbnailMini(ctx *common.Context, params common.Parameters) (string, error) {
videoID := params["videoID"]
video := &model.Video{ID: videoID}
result := ctx.DB.First(video)
if result.RowsAffected < 1 {
return "video does not exist", errors.New("id not in db")
}
store, err := ctx.StorageResolver.Storage(videoLibraryID(ctx, video))
if err != nil {
return "unable to resolve storage", err
}
_ = store.Delete(video.ThumbnailXSRelativePath())
return "", nil
}
package video
import (
"errors"
"io"
"os"
"os/exec"
"path/filepath"
"github.com/zobtube/zobtube/internal/model"
"github.com/zobtube/zobtube/internal/storage"
"github.com/zobtube/zobtube/internal/task/common"
)
func generateThumbnail(ctx *common.Context, params common.Parameters) (string, error) {
id := params["videoID"]
timing := params["thumbnailTiming"]
video := &model.Video{ID: id}
result := ctx.DB.First(video)
if result.RowsAffected < 1 {
return "video does not exist", errors.New("id not in db")
}
store, err := ctx.StorageResolver.Storage(videoLibraryID(ctx, video))
if err != nil {
return "unable to resolve storage", err
}
videoLocal, cleanupVideo, err := storage.LocalPathForRead(store, video.RelativePath())
if err != nil {
return "unable to get local path for video", err
}
defer cleanupVideo()
thumbPath := video.ThumbnailRelativePath()
thumbTemp, err := os.CreateTemp("", "zt-thumb-*.jpg")
if err != nil {
return "unable to create temp for thumbnail", err
}
thumbTempPath := thumbTemp.Name()
thumbTemp.Close()
defer os.Remove(thumbTempPath)
// #nosec G204
_, err = exec.Command(
"ffmpeg", "-y", "-ss", timing,
"-i", videoLocal,
"-frames:v", "1", "-q:v", "2",
thumbTempPath,
).Output()
if err != nil {
return "unable to generate thumbnail with ffmpeg", err
}
if err := store.MkdirAll(filepath.Dir(thumbPath)); err != nil {
return "unable to create thumbnail folder", err
}
r, err := os.Open(thumbTempPath)
if err != nil {
return "unable to open temp thumbnail", err
}
defer r.Close()
w, err := store.Create(thumbPath)
if err != nil {
return "unable to create thumbnail file", err
}
defer w.Close()
if _, err := io.Copy(w, r); err != nil {
return "unable to write thumbnail", err
}
video.Thumbnail = true
ctx.DB.Save(&video)
return "", nil
}
func deleteThumbnail(ctx *common.Context, params common.Parameters) (string, error) {
videoID := params["videoID"]
video := &model.Video{ID: videoID}
result := ctx.DB.First(video)
if result.RowsAffected < 1 {
return "video does not exist", errors.New("id not in db")
}
store, err := ctx.StorageResolver.Storage(videoLibraryID(ctx, video))
if err != nil {
return "unable to resolve storage", err
}
thumbPath := video.ThumbnailRelativePath()
_ = store.Delete(thumbPath)
return "", nil
}
package video
import (
"errors"
"io"
"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) {
id := params["videoID"]
video := &model.Video{ID: id}
result := ctx.DB.First(video)
if result.RowsAffected < 1 {
return "video does not exist", errors.New("id not in db")
}
libID := videoLibraryID(ctx, video)
store, err := ctx.StorageResolver.Storage(libID)
if err != nil {
return "unable to resolve storage", err
}
triagePath := filepath.Join("triage", video.Filename)
newPath := video.RelativePath()
if err := store.MkdirAll(filepath.Dir(newPath)); err != nil {
return "unable to create new video folder", err
}
rc, err := store.Open(triagePath)
if err != nil {
return "unable to open triage file", err
}
defer rc.Close()
wc, err := store.Create(newPath)
if err != nil {
return "unable to create video file", err
}
defer wc.Close()
if _, err := io.Copy(wc, rc); err != nil {
return "unable to copy video", err
}
_ = store.Delete(triagePath)
video.Imported = true
if err := ctx.DB.Save(video).Error; err != nil {
return "unable to update database", err
}
return "", nil
}
func videoLibraryID(ctx *common.Context, video *model.Video) string {
if video.LibraryID != nil && *video.LibraryID != "" {
return *video.LibraryID
}
return ctx.Config.DefaultLibraryID
}
package main
import (
"context"
"embed"
"fmt"
"net/mail"
"os"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
altsrc "github.com/urfave/cli-altsrc/v3"
"github.com/urfave/cli-altsrc/v3/yaml"
"github.com/urfave/cli/v3"
"github.com/zobtube/zobtube/cli/passwordreset"
"github.com/zobtube/zobtube/cli/server"
)
//go:embed web
var webFS embed.FS
// goreleaser build-time variables
var (
version = "dev"
commit = "none"
date = "unknown"
)
// global logger
var logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339})
// @title ZobTube
// @description ZobTube is a video management system.
// @contact.name ZobTube Issues
// @contact.url https://github.com/zobtube/zobtube/issues
// @license.name MIT
// @license.url https://github.com/zobtube/zobtube?tab=MIT-1-ov-file#readme
// @BasePath /api
// @schemes http https
func main() {
var configurationFile string
cmd := &cli.Command{
Name: "zobtube",
Usage: "passion of the zob, lube for the tube!",
Version: fmt.Sprintf("%s (commit %s), built at %s", version, commit, date),
Copyright: "(c) 2025 ZobTube",
Authors: []any{
mail.Address{Name: "sblablaha", Address: "sblablaha@gmail.com"},
},
Flags: []cli.Flag{
&cli.StringFlag{
Name: "config-file",
Usage: "path to configuration file",
Sources: cli.EnvVars("ZT_CONFIG_FILE"),
Value: "config.yml",
Destination: &configurationFile,
},
&cli.BoolFlag{
Name: "gin-debug",
Usage: "enables gin debugging mode",
Sources: cli.NewValueSourceChain(
cli.EnvVar("ZT_GIN_DEBUG"),
yaml.YAML("log.gin.debug", altsrc.NewStringPtrSourcer(&configurationFile)),
),
},
&cli.IntFlag{
Name: "log-level",
Usage: "select log verbosity (5: panic / 4: fatal / 3: error / 2: warn / 1: info / 0: debug / -1: trace)",
Sources: cli.NewValueSourceChain(
cli.EnvVar("ZT_LOG_LEVEL"),
yaml.YAML("log.level", altsrc.NewStringPtrSourcer(&configurationFile)),
),
Value: 1,
DefaultText: "1 - info",
Action: func(ctx context.Context, cmd *cli.Command, v int) error {
if v < -1 || v > 5 {
return fmt.Errorf("parameter log-level value %v out of range (must be between -1 and 5)", v)
}
return nil
},
},
&cli.StringFlag{
Name: "server-bind",
Usage: "address the http server will bind to",
Sources: cli.NewValueSourceChain(
cli.EnvVar("ZT_SERVER_BIND"),
yaml.YAML("server.bind", altsrc.NewStringPtrSourcer(&configurationFile)),
),
Value: "0.0.0.0:8069",
},
&cli.StringFlag{
Name: "db-driver",
Usage: "database driver to use (sqlite or postgresql)",
Sources: cli.NewValueSourceChain(
cli.EnvVar("ZT_DB_DRIVER"),
yaml.YAML("db.driver", altsrc.NewStringPtrSourcer(&configurationFile)),
),
Value: "sqlite",
},
&cli.StringFlag{
Name: "db-connstring",
Usage: "connection string to the database",
Sources: cli.NewValueSourceChain(
cli.EnvVar("ZT_DB_CONNSTRING"),
yaml.YAML("db.connstring", altsrc.NewStringPtrSourcer(&configurationFile)),
),
Value: "zobtube.sqlite",
},
&cli.StringFlag{
Name: "media-path",
Usage: "path to the media folder",
Sources: cli.NewValueSourceChain(
cli.EnvVar("ZT_MEDIA_PATH"),
yaml.YAML("media.path", altsrc.NewStringPtrSourcer(&configurationFile)),
),
Value: "data",
},
},
Action: startServer,
Commands: []*cli.Command{
{
Name: "server",
Action: startServer,
Usage: "start zobtube server, default action if no command passed",
},
{
Name: "password-reset",
Category: "user",
Usage: "reset password of a user interactively",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "user-id",
Usage: "user id to reset password. If empty, will list all users with their ids",
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
return passwordreset.Run(cmd, &logger)
},
},
},
}
err := cmd.Run(context.Background(), os.Args)
if err != nil {
logger.Error().Err(err).Send()
}
}
func startServer(ctx context.Context, cmd *cli.Command) error {
return server.Start(&server.Parameters{
Ctx: ctx,
Cmd: cmd,
Logger: &logger,
Version: version,
Commit: commit,
Date: date,
WebFS: &webFS,
})
}