vodular/server/server.go

483 lines
13 KiB
Go
Raw Normal View History

2026-06-08 02:49:39 +01:00
package server
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"path"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/pelletier/go-toml/v2"
"arimelody.space/melody-vod-manager/public"
"arimelody.space/melody-vod-manager/scanner"
"arimelody.space/melody-vod-manager/templates"
)
type (
VODGoogleConfig struct {
ApiKey string
ClientID string
ClientSecret string
}
VODServerConfig struct {
Host string
Port int16
VODDirectory string
TemplateDirectory string
}
VODServer struct {
config VODServerConfig
google VODGoogleConfig
googleToken string
textTemplates *templates.TextTemplates
started sync.Mutex
logger *slog.Logger
router *gin.Engine
}
)
func New(config VODServerConfig, google VODGoogleConfig, textTemplates *templates.TextTemplates) (*VODServer, error) {
server := &VODServer{
started: sync.Mutex{},
logger: slog.Default().WithGroup("VOD-MANAGER"),
router: gin.Default(),
}
server.config = config
server.google = google
server.textTemplates = textTemplates
return server, nil
}
func (srv *VODServer) Run(ctx context.Context) error {
if !srv.started.TryLock() {
return errors.New("server already started")
}
defer srv.started.Unlock()
failed := make(chan error)
go func() {
err := srv.start(ctx)
if !errors.Is(err, context.Canceled) {
failed <- err
}
}()
select {
case err := <-failed:
return err
case <-ctx.Done():
srv.stop(ctx)
return context.Canceled
}
}
func (srv *VODServer) handleGetVOD(ctx *gin.Context, vodDir string, metadata *scanner.Metadata) {
if ctx.Request.Header.Get("Accept") == "application/json" {
ctx.JSON(http.StatusOK, metadata)
return
}
type (
VODFootageBlock struct {
Name string
Size string
SizeBytes int64
}
VOD struct {
Title string
Part uint
Date time.Time
TitleFormatted string
Tags []string
Category *scanner.Category
DescriptionFormatted string
Uploaded bool
FootageDir string
Footage []VODFootageBlock
Directory string
}
)
var err error
textTemplateData := templates.TextTemplateData{
Title: metadata.Title,
Part: uint(metadata.Part),
Date: metadata.Date.AsTime(time.UTC),
Category: metadata.Category,
}
titleFormatted := bytes.Buffer{}
err = srv.textTemplates.Title.Execute(&titleFormatted, textTemplateData)
if err != nil {
srv.logger.Error("Failed to format VOD title:", "dir", vodDir, "err", err)
ctx.String(
http.StatusInternalServerError,
http.StatusText(http.StatusInternalServerError),
)
return
}
descriptionFormatted := bytes.Buffer{}
err = srv.textTemplates.Description.Execute(&descriptionFormatted, textTemplateData)
if err != nil {
srv.logger.Error("Failed to format VOD description:", "dir", vodDir, "err", err)
ctx.String(
http.StatusInternalServerError,
http.StatusText(http.StatusInternalServerError),
)
return
}
vod := VOD{
Title: metadata.Title,
Part: uint(metadata.Part),
Date: metadata.Date.AsTime(time.UTC),
TitleFormatted: titleFormatted.String(),
Tags: metadata.Tags,
Category: metadata.Category,
DescriptionFormatted: descriptionFormatted.String(),
Uploaded: metadata.Uploaded,
FootageDir: metadata.FootageDir,
Footage: []VODFootageBlock{},
Directory: strings.TrimPrefix(vodDir[:strings.LastIndex(vodDir, "/")], srv.config.VODDirectory),
}
footageDir := path.Join(vodDir, metadata.FootageDir)
files, err := os.ReadDir(footageDir)
if err != nil {
srv.logger.Error("Failed to read footage directory:", "dir", footageDir, "err", err)
ctx.String(
http.StatusInternalServerError,
http.StatusText(http.StatusInternalServerError),
)
}
for _, file := range files {
if file.IsDir() || !strings.HasSuffix(file.Name(), "mkv") { continue }
filePath := path.Join(vodDir, metadata.FootageDir, file.Name())
stat, err := os.Stat(filePath)
if err != nil {
srv.logger.Error("Failed to stat file:", "path", filePath, "err", err)
ctx.String(
http.StatusInternalServerError,
http.StatusText(http.StatusInternalServerError),
)
}
vod.Footage = append(vod.Footage, VODFootageBlock{
Name: file.Name(),
Size: scanner.FileSizeString(stat.Size()),
SizeBytes: stat.Size(),
})
}
if err := templates.VODTemplate.Execute(ctx.Writer, vod); err != nil {
srv.logger.Error("Failed to render VOD template:", "err", err)
ctx.String(
http.StatusInternalServerError,
http.StatusText(http.StatusInternalServerError),
)
}
}
func (srv *VODServer) handleInitVODDirectory (ctx *gin.Context, vodDir string) {
srv.logger.Debug("Initialising VOD directory:", "dir", vodDir)
metadata := scanner.DefaultMetadata()
err := scanner.WriteMetadata(vodDir, metadata)
if err != nil {
srv.logger.Error("Failed to initialise VOD directory:", "dir", vodDir, "err", err)
ctx.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
return
}
ctx.Redirect(
http.StatusFound,
fmt.Sprintf("/vods%s", strings.TrimPrefix(vodDir, srv.config.VODDirectory)),
)
}
func (srv *VODServer) handleListVODs(ctx *gin.Context, queryDir string) {
stat, err := os.Stat(queryDir)
if !stat.IsDir() {
http.ServeFile(ctx.Writer, ctx.Request, queryDir)
return
}
if ctx.Query("init") == "true" {
srv.handleInitVODDirectory(ctx, queryDir)
return
}
metadata, err := scanner.ReadMetadata(queryDir)
if err != nil && !os.IsNotExist(err) {
srv.logger.Error("Failed to read metadata:", "err", err)
ctx.Status(http.StatusInternalServerError)
return
}
if metadata != nil {
srv.handleGetVOD(ctx, queryDir, metadata)
return
}
dirs, err := os.ReadDir(queryDir)
if err != nil {
srv.logger.Error("Failed to read directory:", "dir", queryDir, "err", err)
ctx.Status(http.StatusInternalServerError)
return
}
type (
VODListItem struct {
Title string `json:"title"`
Path string `json:"path"`
}
FileItem struct {
Name string `json:"name"`
Size string `json:"-"`
SizeBytes int64 `json:"size"`
}
ListVODsResponse struct {
VODs []VODListItem `json:"vods"`
Directories []string `json:"directories"`
Files []FileItem `json:"files"`
}
ListVODsHTMLResponse struct {
ListVODsResponse
Directory string
}
)
res := ListVODsResponse{
VODs: []VODListItem{},
Directories: []string{},
Files: []FileItem{},
}
for _, dir := range dirs {
if dir.IsDir() {
fullPath := path.Join(queryDir, dir.Name())
metadata, err := scanner.ReadMetadata(fullPath)
if err != nil && !os.IsNotExist(err) {
slog.Error("Failed to read metadata:", "dir", fullPath, "err", err)
// soft fail (list as directory)
}
if metadata != nil {
titleFormatted := &bytes.Buffer{}
if err := srv.textTemplates.Title.Execute(titleFormatted, templates.TextTemplateData{
Title: metadata.Title,
Part: uint(metadata.Part),
Date: metadata.Date.AsTime(time.UTC),
Category: metadata.Category,
}); err != nil {
srv.logger.Error("Failed to format VOD title:", "vodPath", fullPath, "err", err)
continue
}
res.VODs = append(res.VODs, VODListItem{
Title: strings.TrimSpace(titleFormatted.String()),
Path: dir.Name(),
})
} else {
res.Directories = append(res.Directories, dir.Name())
}
} else {
filePath := path.Join(queryDir, dir.Name())
stat, err := os.Stat(filePath)
if err != nil {
srv.logger.Error("Failed to stat file:", "path", filePath, "err", err)
continue
}
res.Files = append(res.Files, FileItem{
Name: stat.Name(),
Size: scanner.FileSizeString(stat.Size()),
SizeBytes: stat.Size(),
})
}
}
ctx.Header("cache-control", "max-age=0")
if ctx.GetHeader("accept") == "application/json" {
ctx.JSON(http.StatusOK, res)
} else {
if err := templates.VODListTemplate.Execute(ctx.Writer, ListVODsHTMLResponse{
ListVODsResponse: res,
Directory: strings.TrimPrefix(queryDir, srv.config.VODDirectory),
}); err != nil {
srv.logger.Error("Failed to render VOD list template:", "err", err)
ctx.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
}
}
}
func (srv *VODServer) handleUpdateVOD(ctx *gin.Context, vodDir string) {
metadata, err := scanner.ReadMetadata(vodDir)
if err != nil {
if os.IsNotExist(err) {
http.NotFound(ctx.Writer, ctx.Request)
return
}
ctx.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
return
}
if title := ctx.PostForm("title"); title != "" { metadata.Title = title }
if part, err := strconv.Atoi(ctx.PostForm("part")); err == nil && part > 0 { metadata.Part = part }
if date, err := time.Parse("2006-01-02", ctx.PostForm("date")); err == nil { metadata.Date = toml.LocalDate{
Year: date.Year(), Month: int(date.Month()), Day: date.Day(),
} }
if ctx.PostForm("category-enabled") == "on" {
metadata.Category = &scanner.Category{}
if categoryName := ctx.PostForm("category-name"); categoryName != "" { metadata.Category.Name = categoryName }
if categoryType := ctx.PostForm("category-type"); categoryType != "" { metadata.Category.Type = categoryType }
if categoryUrl := ctx.PostForm("category-url"); categoryUrl != "" { metadata.Category.Url = categoryUrl }
} else {
metadata.Category = nil
}
err = scanner.WriteMetadata(vodDir, metadata)
if err != nil {
srv.logger.Error("Failed to write VOD metadata:", "dir", vodDir, "err", err)
ctx.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
return
}
ctx.Redirect(
http.StatusFound,
fmt.Sprintf("/vods%s", strings.TrimPrefix(vodDir, srv.config.VODDirectory)),
)
}
func (srv *VODServer) start(ctx context.Context) error {
srv.logger.Info("Starting server...")
srv.router.GET("/_health", func(ctx *gin.Context) {
ctx.String(http.StatusOK, http.StatusText(http.StatusOK))
})
srv.router.GET("/", func(ctx *gin.Context) {
if err := templates.IndexTemplate.Execute(ctx.Writer, nil); err != nil {
ctx.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
}
})
srv.router.GET("/vods", func(ctx *gin.Context) {
srv.handleListVODs(ctx, srv.config.VODDirectory)
})
srv.router.GET("/vods/*path", func(ctx *gin.Context) {
srv.handleListVODs(ctx, path.Join(srv.config.VODDirectory, ctx.Param("path")))
})
srv.router.POST("/vods/*path", func(ctx *gin.Context) {
srv.handleUpdateVOD(ctx, path.Join(srv.config.VODDirectory, ctx.Param("path")))
})
srv.router.GET("/sse", func(ctx *gin.Context) {
queryEscape, err := url.PathUnescape(ctx.Query("vod"))
if err != nil {
srv.logger.Error("Failed to parse SSE query string:", "err", err)
ctx.String(http.StatusBadRequest, http.StatusText(http.StatusBadRequest))
return
}
vodDir := path.Join(srv.config.VODDirectory, queryEscape)
srv.logger.Debug("SSE connection:", "vod", vodDir)
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
ctx.Stream(func(w io.Writer) bool {
<-ticker.C
ctx.SSEvent("", gin.H{
"state": "uploading",
"progress": "13.37",
})
return true
})
})
srv.router.GET("/templates/title", func(ctx *gin.Context) {
tmpl := templates.DefaultTitleTemplate
var titlePath = path.Join(srv.config.TemplateDirectory, "title.txt")
if titleFile, err := os.ReadFile(titlePath); err == nil {
ctx.String(http.StatusOK, string(titleFile))
return
}
ctx.String(http.StatusOK, tmpl)
})
srv.router.GET("/templates/description", func(ctx *gin.Context) {
tmpl := templates.DefaultDescriptionTemplate
var descriptionPath = path.Join(srv.config.TemplateDirectory, "description.txt")
if descriptionFile, err := os.ReadFile(descriptionPath); err == nil {
ctx.String(http.StatusOK, string(descriptionFile))
return
}
ctx.String(http.StatusOK, tmpl)
})
srv.router.GET("/templates/tags", func(ctx *gin.Context) {
var tagsPath = path.Join(srv.config.TemplateDirectory, "tags.txt")
ctx.Header("content-type", "application/json")
if tagsFile, err := os.ReadFile(tagsPath); err == nil {
tags := strings.Split(strings.TrimSuffix(string(tagsFile), "\n"), "\n")
res := strings.Builder{}
res.WriteRune('[')
for i, tag := range tags {
fmt.Fprintf(&res, "\"%s\"", tag)
if i < len(tags) - 1 { res.WriteString(", ") }
}
res.WriteRune(']')
ctx.String(http.StatusOK, res.String())
return
}
ctx.String(http.StatusOK, "[]")
})
srv.router.GET("/style/*path", func(ctx *gin.Context) {
path := strings.TrimPrefix(ctx.Request.URL.Path, "/")
http.ServeFileFS(ctx.Writer, ctx.Request, public.StyleFS, path)
})
srv.router.GET("/script/*path", func(ctx *gin.Context) {
path := strings.TrimPrefix(ctx.Request.URL.Path, "/")
http.ServeFileFS(ctx.Writer, ctx.Request, public.ScriptFS, path)
})
failed := make(chan error)
go func() {
srv.logger.Info("Server online.", "host", srv.config.Host, "port", srv.config.Port)
failed <- srv.router.Run(fmt.Sprintf(":%d", srv.config.Port))
}()
select {
case err := <-failed:
return err
case <-ctx.Done():
return context.Canceled
}
}
func (srv *VODServer) stop(_ context.Context) {
srv.logger.Info("Shutting down...")
}