node build fixed

This commit is contained in:
ra_ma
2025-09-20 14:08:38 +01:00
parent c6ebbe069d
commit 3d298fa434
1516 changed files with 535727 additions and 2 deletions

View File

@@ -0,0 +1,599 @@
package handlers
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"runtime"
"runtime/pprof"
"seanime/internal/constants"
"seanime/internal/core"
"seanime/internal/database/models"
"seanime/internal/user"
"seanime/internal/util"
"seanime/internal/util/result"
"slices"
"strconv"
"strings"
"time"
"github.com/labstack/echo/v4"
)
// Status is a struct containing the user data, settings, and OS.
// It is used by the client in various places to access necessary information.
type Status struct {
OS string `json:"os"`
ClientDevice string `json:"clientDevice"`
ClientPlatform string `json:"clientPlatform"`
ClientUserAgent string `json:"clientUserAgent"`
DataDir string `json:"dataDir"`
User *user.User `json:"user"`
Settings *models.Settings `json:"settings"`
Version string `json:"version"`
VersionName string `json:"versionName"`
ThemeSettings *models.Theme `json:"themeSettings"`
IsOffline bool `json:"isOffline"`
MediastreamSettings *models.MediastreamSettings `json:"mediastreamSettings"`
TorrentstreamSettings *models.TorrentstreamSettings `json:"torrentstreamSettings"`
DebridSettings *models.DebridSettings `json:"debridSettings"`
AnilistClientID string `json:"anilistClientId"`
Updating bool `json:"updating"` // If true, a new screen will be displayed
IsDesktopSidecar bool `json:"isDesktopSidecar"` // The server is running as a desktop sidecar
FeatureFlags core.FeatureFlags `json:"featureFlags"`
ServerReady bool `json:"serverReady"`
ServerHasPassword bool `json:"serverHasPassword"`
}
var clientInfoCache = result.NewResultMap[string, util.ClientInfo]()
// NewStatus returns a new Status struct.
// It uses the RouteCtx to get the App instance containing the Database instance.
func (h *Handler) NewStatus(c echo.Context) *Status {
var dbAcc *models.Account
var currentUser *user.User
var settings *models.Settings
var theme *models.Theme
//var mal *models.Mal
// Get the user from the database (if logged in)
if dbAcc, _ = h.App.Database.GetAccount(); dbAcc != nil {
currentUser, _ = user.NewUser(dbAcc)
if currentUser != nil {
currentUser.Token = "HIDDEN"
}
} else {
// If the user is not logged in, create a simulated user
currentUser = user.NewSimulatedUser()
}
if settings, _ = h.App.Database.GetSettings(); settings != nil {
if settings.ID == 0 || settings.Library == nil || settings.Torrent == nil || settings.MediaPlayer == nil {
settings = nil
}
}
clientInfo, found := clientInfoCache.Get(c.Request().UserAgent())
if !found {
clientInfo = util.GetClientInfo(c.Request().UserAgent())
clientInfoCache.Set(c.Request().UserAgent(), clientInfo)
}
theme, _ = h.App.Database.GetTheme()
status := &Status{
OS: runtime.GOOS,
ClientDevice: clientInfo.Device,
ClientPlatform: clientInfo.Platform,
DataDir: h.App.Config.Data.AppDataDir,
ClientUserAgent: c.Request().UserAgent(),
User: currentUser,
Settings: settings,
Version: h.App.Version,
VersionName: constants.VersionName,
ThemeSettings: theme,
IsOffline: h.App.Config.Server.Offline,
MediastreamSettings: h.App.SecondarySettings.Mediastream,
TorrentstreamSettings: h.App.SecondarySettings.Torrentstream,
DebridSettings: h.App.SecondarySettings.Debrid,
AnilistClientID: h.App.Config.Anilist.ClientID,
Updating: false,
IsDesktopSidecar: h.App.IsDesktopSidecar,
FeatureFlags: h.App.FeatureFlags,
ServerReady: h.App.ServerReady,
ServerHasPassword: h.App.Config.Server.Password != "",
}
if c.Get("unauthenticated") != nil && c.Get("unauthenticated").(bool) {
// If the user is unauthenticated, return a status with no user data
status.OS = ""
status.DataDir = ""
status.User = user.NewSimulatedUser()
status.ThemeSettings = nil
status.MediastreamSettings = nil
status.TorrentstreamSettings = nil
status.Settings = &models.Settings{}
status.DebridSettings = nil
status.FeatureFlags = core.FeatureFlags{}
}
return status
}
// HandleGetStatus
//
// @summary returns the server status.
// @desc The server status includes app info, auth info and settings.
// @desc The client uses this to set the UI.
// @desc It is called on every page load to get the most up-to-date data.
// @desc It should be called right after updating the settings.
// @route /api/v1/status [GET]
// @returns handlers.Status
func (h *Handler) HandleGetStatus(c echo.Context) error {
status := h.NewStatus(c)
return h.RespondWithData(c, status)
}
func (h *Handler) HandleGetLogContent(c echo.Context) error {
if h.App.Config == nil || h.App.Config.Logs.Dir == "" {
return h.RespondWithData(c, "")
}
filename := c.Param("*")
if filepath.Base(filename) != filename {
h.App.Logger.Error().Msg("handlers: Invalid filename")
return h.RespondWithError(c, fmt.Errorf("invalid filename"))
}
fp := filepath.Join(h.App.Config.Logs.Dir, filename)
if filepath.Ext(fp) != ".log" {
h.App.Logger.Error().Msg("handlers: Unsupported file extension")
return h.RespondWithError(c, fmt.Errorf("unsupported file extension"))
}
if _, err := os.Stat(fp); err != nil {
h.App.Logger.Error().Err(err).Msg("handlers: Stat error")
return h.RespondWithError(c, err)
}
contentB, err := os.ReadFile(fp)
if err != nil {
h.App.Logger.Error().Err(err).Msg("handlers: Failed to read log file")
return h.RespondWithError(c, err)
}
return h.RespondWithData(c, string(contentB))
}
var newestLogFilename = ""
// HandleGetLogFilenames
//
// @summary returns the log filenames.
// @desc This returns the filenames of all log files in the logs directory.
// @route /api/v1/logs/filenames [GET]
// @returns []string
func (h *Handler) HandleGetLogFilenames(c echo.Context) error {
if h.App.Config == nil || h.App.Config.Logs.Dir == "" {
return h.RespondWithData(c, []string{})
}
var filenames []string
filepath.WalkDir(h.App.Config.Logs.Dir, func(path string, d fs.DirEntry, err error) error {
if d.IsDir() {
return nil
}
if filepath.Ext(path) != ".log" {
return nil
}
filenames = append(filenames, filepath.Base(path))
return nil
})
// Sort from newest to oldest & store the newest log filename
if len(filenames) > 0 {
slices.SortStableFunc(filenames, func(i, j string) int {
return strings.Compare(j, i)
})
for _, filename := range filenames {
if strings.HasPrefix(strings.ToLower(filename), "seanime-") {
newestLogFilename = filename
break
}
}
}
return h.RespondWithData(c, filenames)
}
// HandleDeleteLogs
//
// @summary deletes certain log files.
// @desc This deletes the log files with the given filenames.
// @route /api/v1/logs [DELETE]
// @returns bool
func (h *Handler) HandleDeleteLogs(c echo.Context) error {
type body struct {
Filenames []string `json:"filenames"`
}
if h.App.Config == nil || h.App.Config.Logs.Dir == "" {
return h.RespondWithData(c, false)
}
var b body
if err := c.Bind(&b); err != nil {
return h.RespondWithError(c, err)
}
filepath.WalkDir(h.App.Config.Logs.Dir, func(path string, d fs.DirEntry, err error) error {
if d.IsDir() {
return nil
}
if filepath.Ext(path) != ".log" {
return nil
}
for _, filename := range b.Filenames {
if util.NormalizePath(filepath.Base(path)) == util.NormalizePath(filename) {
if util.NormalizePath(newestLogFilename) == util.NormalizePath(filename) {
return fmt.Errorf("cannot delete the newest log file")
}
if err := os.Remove(path); err != nil {
return err
}
}
}
return nil
})
return h.RespondWithData(c, true)
}
// HandleGetLatestLogContent
//
// @summary returns the content of the latest server log file.
// @desc This returns the content of the most recent seanime- log file after flushing logs.
// @route /api/v1/logs/latest [GET]
// @returns string
func (h *Handler) HandleGetLatestLogContent(c echo.Context) error {
if h.App.Config == nil || h.App.Config.Logs.Dir == "" {
return h.RespondWithData(c, "")
}
// Flush logs first
if h.App.OnFlushLogs != nil {
h.App.OnFlushLogs()
// Small delay to ensure logs are written
time.Sleep(100 * time.Millisecond)
}
dirEntries, err := os.ReadDir(h.App.Config.Logs.Dir)
if err != nil {
h.App.Logger.Error().Err(err).Msg("handlers: Failed to read log directory")
return h.RespondWithError(c, err)
}
var logFiles []string
for _, entry := range dirEntries {
if entry.IsDir() {
continue
}
name := entry.Name()
if filepath.Ext(name) != ".log" || !strings.HasPrefix(strings.ToLower(name), "seanime-") {
continue
}
logFiles = append(logFiles, filepath.Join(h.App.Config.Logs.Dir, name))
}
if len(logFiles) == 0 {
h.App.Logger.Warn().Msg("handlers: No log files found")
return h.RespondWithData(c, "")
}
// Sort files in descending order based on filename
slices.SortFunc(logFiles, func(a, b string) int {
return strings.Compare(filepath.Base(b), filepath.Base(a))
})
latestLogFile := logFiles[0]
contentB, err := os.ReadFile(latestLogFile)
if err != nil {
h.App.Logger.Error().Err(err).Msg("handlers: Failed to read latest log file")
return h.RespondWithError(c, err)
}
return h.RespondWithData(c, string(contentB))
}
// HandleGetAnnouncements
//
// @summary returns the server announcements.
// @desc This returns the announcements for the server.
// @route /api/v1/announcements [POST]
// @returns []updater.Announcement
func (h *Handler) HandleGetAnnouncements(c echo.Context) error {
type body struct {
Platform string `json:"platform"`
}
var b body
if err := c.Bind(&b); err != nil {
return h.RespondWithError(c, err)
}
settings, _ := h.App.Database.GetSettings()
announcements := h.App.Updater.GetAnnouncements(h.App.Version, b.Platform, settings)
return h.RespondWithData(c, announcements)
}
type MemoryStatsResponse struct {
Alloc uint64 `json:"alloc"` // bytes allocated and not yet freed
TotalAlloc uint64 `json:"totalAlloc"` // bytes allocated (even if freed)
Sys uint64 `json:"sys"` // bytes obtained from system
Lookups uint64 `json:"lookups"` // number of pointer lookups
Mallocs uint64 `json:"mallocs"` // number of mallocs
Frees uint64 `json:"frees"` // number of frees
HeapAlloc uint64 `json:"heapAlloc"` // bytes allocated and not yet freed
HeapSys uint64 `json:"heapSys"` // bytes obtained from system
HeapIdle uint64 `json:"heapIdle"` // bytes in idle spans
HeapInuse uint64 `json:"heapInuse"` // bytes in non-idle span
HeapReleased uint64 `json:"heapReleased"` // bytes released to OS
HeapObjects uint64 `json:"heapObjects"` // total number of allocated objects
StackInuse uint64 `json:"stackInuse"` // bytes used by stack allocator
StackSys uint64 `json:"stackSys"` // bytes obtained from system for stack allocator
MSpanInuse uint64 `json:"mSpanInuse"` // bytes used by mspan structures
MSpanSys uint64 `json:"mSpanSys"` // bytes obtained from system for mspan structures
MCacheInuse uint64 `json:"mCacheInuse"` // bytes used by mcache structures
MCacheSys uint64 `json:"mCacheSys"` // bytes obtained from system for mcache structures
BuckHashSys uint64 `json:"buckHashSys"` // bytes used by the profiling bucket hash table
GCSys uint64 `json:"gcSys"` // bytes used for garbage collection system metadata
OtherSys uint64 `json:"otherSys"` // bytes used for other system allocations
NextGC uint64 `json:"nextGC"` // next collection will happen when HeapAlloc ≥ this amount
LastGC uint64 `json:"lastGC"` // time the last garbage collection finished
PauseTotalNs uint64 `json:"pauseTotalNs"` // cumulative nanoseconds in GC stop-the-world pauses
PauseNs uint64 `json:"pauseNs"` // nanoseconds in recent GC stop-the-world pause
NumGC uint32 `json:"numGC"` // number of completed GC cycles
NumForcedGC uint32 `json:"numForcedGC"` // number of GC cycles that were forced by the application calling the GC function
GCCPUFraction float64 `json:"gcCPUFraction"` // fraction of this program's available CPU time used by the GC since the program started
EnableGC bool `json:"enableGC"` // boolean that indicates GC is enabled
DebugGC bool `json:"debugGC"` // boolean that indicates GC debug mode is enabled
NumGoroutine int `json:"numGoroutine"` // number of goroutines
}
// HandleGetMemoryStats
//
// @summary returns current memory statistics.
// @desc This returns real-time memory usage statistics from the Go runtime.
// @route /api/v1/memory/stats [GET]
// @returns handlers.MemoryStatsResponse
func (h *Handler) HandleGetMemoryStats(c echo.Context) error {
var m runtime.MemStats
runtime.ReadMemStats(&m)
// Force garbage collection to get accurate memory stats
// runtime.GC()
runtime.ReadMemStats(&m)
response := MemoryStatsResponse{
Alloc: m.Alloc,
TotalAlloc: m.TotalAlloc,
Sys: m.Sys,
Lookups: m.Lookups,
Mallocs: m.Mallocs,
Frees: m.Frees,
HeapAlloc: m.HeapAlloc,
HeapSys: m.HeapSys,
HeapIdle: m.HeapIdle,
HeapInuse: m.HeapInuse,
HeapReleased: m.HeapReleased,
HeapObjects: m.HeapObjects,
StackInuse: m.StackInuse,
StackSys: m.StackSys,
MSpanInuse: m.MSpanInuse,
MSpanSys: m.MSpanSys,
MCacheInuse: m.MCacheInuse,
MCacheSys: m.MCacheSys,
BuckHashSys: m.BuckHashSys,
GCSys: m.GCSys,
OtherSys: m.OtherSys,
NextGC: m.NextGC,
LastGC: m.LastGC,
PauseTotalNs: m.PauseTotalNs,
PauseNs: m.PauseNs[0], // Most recent pause
NumGC: m.NumGC,
NumForcedGC: m.NumForcedGC,
GCCPUFraction: m.GCCPUFraction,
EnableGC: m.EnableGC,
DebugGC: m.DebugGC,
NumGoroutine: runtime.NumGoroutine(),
}
return h.RespondWithData(c, response)
}
// HandleGetMemoryProfile
//
// @summary generates and returns a memory profile.
// @desc This generates a memory profile that can be analyzed with go tool pprof.
// @desc Query parameters: heap=true for heap profile, allocs=true for alloc profile.
// @route /api/v1/memory/profile [GET]
// @returns nil
func (h *Handler) HandleGetMemoryProfile(c echo.Context) error {
// Parse query parameters
heap := c.QueryParam("heap") == "true"
allocs := c.QueryParam("allocs") == "true"
// Default to heap profile if no specific type requested
if !heap && !allocs {
heap = true
}
// Set response headers for file download
timestamp := time.Now().Format("2006-01-02_15-04-05")
var filename string
var profile *pprof.Profile
var err error
if heap {
filename = fmt.Sprintf("seanime-heap-profile-%s.pprof", timestamp)
profile = pprof.Lookup("heap")
} else if allocs {
filename = fmt.Sprintf("seanime-allocs-profile-%s.pprof", timestamp)
profile = pprof.Lookup("allocs")
}
if profile == nil {
h.App.Logger.Error().Msg("handlers: Failed to lookup memory profile")
return h.RespondWithError(c, fmt.Errorf("failed to lookup memory profile"))
}
c.Response().Header().Set("Content-Type", "application/octet-stream")
c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
// // Force garbage collection before profiling for more accurate results
// runtime.GC()
// Write profile to response
if err = profile.WriteTo(c.Response().Writer, 0); err != nil {
h.App.Logger.Error().Err(err).Msg("handlers: Failed to write memory profile")
return h.RespondWithError(c, err)
}
return nil
}
// HandleGetGoRoutineProfile
//
// @summary generates and returns a goroutine profile.
// @desc This generates a goroutine profile showing all running goroutines and their stack traces.
// @route /api/v1/memory/goroutine [GET]
// @returns nil
func (h *Handler) HandleGetGoRoutineProfile(c echo.Context) error {
timestamp := time.Now().Format("2006-01-02_15-04-05")
filename := fmt.Sprintf("seanime-goroutine-profile-%s.pprof", timestamp)
profile := pprof.Lookup("goroutine")
if profile == nil {
h.App.Logger.Error().Msg("handlers: Failed to lookup goroutine profile")
return h.RespondWithError(c, fmt.Errorf("failed to lookup goroutine profile"))
}
c.Response().Header().Set("Content-Type", "application/octet-stream")
c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
if err := profile.WriteTo(c.Response().Writer, 0); err != nil {
h.App.Logger.Error().Err(err).Msg("handlers: Failed to write goroutine profile")
return h.RespondWithError(c, err)
}
return nil
}
// HandleGetCPUProfile
//
// @summary generates and returns a CPU profile.
// @desc This generates a CPU profile for the specified duration (default 30 seconds).
// @desc Query parameter: duration=30 for duration in seconds.
// @route /api/v1/memory/cpu [GET]
// @returns nil
func (h *Handler) HandleGetCPUProfile(c echo.Context) error {
// Parse duration from query parameter (default to 30 seconds)
durationStr := c.QueryParam("duration")
duration := 30 * time.Second
if durationStr != "" {
if d, err := strconv.Atoi(durationStr); err == nil && d > 0 && d <= 300 { // Max 5 minutes
duration = time.Duration(d) * time.Second
}
}
timestamp := time.Now().Format("2006-01-02_15-04-05")
filename := fmt.Sprintf("seanime-cpu-profile-%s.pprof", timestamp)
c.Response().Header().Set("Content-Type", "application/octet-stream")
c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
// Start CPU profiling
if err := pprof.StartCPUProfile(c.Response().Writer); err != nil {
h.App.Logger.Error().Err(err).Msg("handlers: Failed to start CPU profile")
return h.RespondWithError(c, err)
}
// Profile for the specified duration
h.App.Logger.Info().Msgf("handlers: Starting CPU profile for %v", duration)
time.Sleep(duration)
// Stop CPU profiling
pprof.StopCPUProfile()
h.App.Logger.Info().Msg("handlers: CPU profile completed")
return nil
}
// HandleForceGC
//
// @summary forces garbage collection and returns memory stats.
// @desc This forces a garbage collection cycle and returns the updated memory statistics.
// @route /api/v1/memory/gc [POST]
// @returns handlers.MemoryStatsResponse
func (h *Handler) HandleForceGC(c echo.Context) error {
h.App.Logger.Info().Msg("handlers: Forcing garbage collection")
// Force garbage collection
runtime.GC()
runtime.GC() // Run twice to ensure cleanup
// Get updated memory stats
var m runtime.MemStats
runtime.ReadMemStats(&m)
response := MemoryStatsResponse{
Alloc: m.Alloc,
TotalAlloc: m.TotalAlloc,
Sys: m.Sys,
Lookups: m.Lookups,
Mallocs: m.Mallocs,
Frees: m.Frees,
HeapAlloc: m.HeapAlloc,
HeapSys: m.HeapSys,
HeapIdle: m.HeapIdle,
HeapInuse: m.HeapInuse,
HeapReleased: m.HeapReleased,
HeapObjects: m.HeapObjects,
StackInuse: m.StackInuse,
StackSys: m.StackSys,
MSpanInuse: m.MSpanInuse,
MSpanSys: m.MSpanSys,
MCacheInuse: m.MCacheInuse,
MCacheSys: m.MCacheSys,
BuckHashSys: m.BuckHashSys,
GCSys: m.GCSys,
OtherSys: m.OtherSys,
NextGC: m.NextGC,
LastGC: m.LastGC,
PauseTotalNs: m.PauseTotalNs,
PauseNs: m.PauseNs[0],
NumGC: m.NumGC,
NumForcedGC: m.NumForcedGC,
GCCPUFraction: m.GCCPUFraction,
EnableGC: m.EnableGC,
DebugGC: m.DebugGC,
NumGoroutine: runtime.NumGoroutine(),
}
h.App.Logger.Info().Msgf("handlers: GC completed, heap size: %d bytes", response.HeapAlloc)
return h.RespondWithData(c, response)
}