483 lines
13 KiB
Go
483 lines
13 KiB
Go
|
|
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...")
|
||
|
|
}
|