HUGE refactor. working towards web UI
This commit is contained in:
parent
dd54e8cc49
commit
1f94eecca9
29 changed files with 1669 additions and 162 deletions
482
server/server.go
Normal file
482
server/server.go
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
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...")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue