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...") }