From c32af8ff664dcf56c4cf2ce93e2f3f7821e83423 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Sat, 27 Jun 2026 02:34:06 +0800 Subject: [PATCH] split server startup helpers --- cmd/server/logging.go | 85 ++++++++++++ cmd/server/main.go | 301 ------------------------------------------ cmd/server/network.go | 51 +++++++ cmd/server/router.go | 156 ++++++++++++++++++++++ cmd/server/startup.go | 46 +++++++ 5 files changed, 338 insertions(+), 301 deletions(-) create mode 100644 cmd/server/logging.go create mode 100644 cmd/server/network.go create mode 100644 cmd/server/router.go create mode 100644 cmd/server/startup.go diff --git a/cmd/server/logging.go b/cmd/server/logging.go new file mode 100644 index 0000000..3d67994 --- /dev/null +++ b/cmd/server/logging.go @@ -0,0 +1,85 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + + "github.com/ShukeBta/MediaStationGo/internal/config" +) + +// newLogger 根据 cfg.Logging 构建 Zap。 +func newLogger(cfg *config.Config) (*zap.Logger, error) { + if cfg.App.Debug { + return zap.NewDevelopment() + } + level := configuredLogLevel(cfg.Logging.Level) + encoderCfg := zap.NewProductionEncoderConfig() + encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder + var encoder zapcore.Encoder + if strings.EqualFold(strings.TrimSpace(cfg.Logging.Format), "console") { + encoder = zapcore.NewConsoleEncoder(encoderCfg) + } else { + encoder = zapcore.NewJSONEncoder(encoderCfg) + } + cores := []zapcore.Core{ + zapcore.NewCore(encoder, zapcore.Lock(os.Stdout), level), + } + appPath, warnPath, errorPath := logFilePaths(cfg) + if appPath != "" { + appWriter, err := newRotatingFileWriter(appPath, cfg.Logging) + if err != nil { + return nil, err + } + cores = append(cores, zapcore.NewCore(encoder, appWriter, level)) + } + if warnPath != "" { + warnWriter, err := newRotatingFileWriter(warnPath, cfg.Logging) + if err != nil { + return nil, err + } + cores = append(cores, zapcore.NewCore(encoder, warnWriter, zap.LevelEnablerFunc(func(lvl zapcore.Level) bool { + return lvl == zapcore.WarnLevel && level.Enabled(lvl) + }))) + } + if errorPath != "" { + errorWriter, err := newRotatingFileWriter(errorPath, cfg.Logging) + if err != nil { + return nil, err + } + cores = append(cores, zapcore.NewCore(encoder, errorWriter, zap.LevelEnablerFunc(func(lvl zapcore.Level) bool { + return lvl >= zapcore.ErrorLevel && level.Enabled(lvl) + }))) + } + return zap.New(zapcore.NewTee(cores...), zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel), zap.ErrorOutput(zapcore.Lock(os.Stderr))), nil +} + +func configuredLogLevel(raw string) zapcore.Level { + level := zapcore.WarnLevel + raw = strings.TrimSpace(raw) + if raw != "" { + var parsed zapcore.Level + if err := parsed.UnmarshalText([]byte(raw)); err == nil { + level = parsed + } + } + return level +} + +func logFilePaths(cfg *config.Config) (string, string, string) { + out := strings.TrimSpace(cfg.Logging.OutputPath) + if strings.EqualFold(out, "stdout") || strings.EqualFold(out, "stderr") { + return "", "", "" + } + if out == "" { + out = filepath.Join(cfg.App.DataDir, "logs") + } + if ext := filepath.Ext(out); ext != "" { + base := strings.TrimSuffix(out, ext) + return out, base + ".warn" + ext, base + ".error" + ext + } + return filepath.Join(out, "app.log"), filepath.Join(out, "warn.log"), filepath.Join(out, "error.log") +} diff --git a/cmd/server/main.go b/cmd/server/main.go index a65755d..1c13b7b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -12,27 +12,20 @@ package main import ( "context" - "database/sql" "errors" "fmt" "net" "net/http" "os" "os/signal" - "path/filepath" - "runtime" - "strings" "syscall" "time" - "github.com/gin-gonic/gin" "go.uber.org/zap" - "go.uber.org/zap/zapcore" "github.com/ShukeBta/MediaStationGo/internal/config" "github.com/ShukeBta/MediaStationGo/internal/database" "github.com/ShukeBta/MediaStationGo/internal/handler" - "github.com/ShukeBta/MediaStationGo/internal/middleware" "github.com/ShukeBta/MediaStationGo/internal/repository" "github.com/ShukeBta/MediaStationGo/internal/service" ) @@ -151,297 +144,3 @@ func main() { services.Close() logger.Info("MediaStationGo stopped") } - -func applyCPUThreadLimit(cfg *config.Config, logger *zap.Logger) { - if cfg == nil || cfg.App.MaxCPUThreads < 1 { - return - } - prev := runtime.GOMAXPROCS(cfg.App.MaxCPUThreads) - if logger != nil { - logger.Info("runtime CPU thread limit applied", - zap.Int("max_cpu_threads", cfg.App.MaxCPUThreads), - zap.Int("previous", prev)) - } -} - -func waitForDatabase(db interface{ DB() (*sql.DB, error) }, logger *zap.Logger) error { - sqlDB, err := db.DB() - if err != nil { - return err - } - var lastErr error - for attempt := 1; attempt <= 30; attempt++ { - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - err = sqlDB.PingContext(ctx) - cancel() - if err == nil { - return nil - } - lastErr = err - if logger != nil { - logger.Warn("database not ready; retrying", zap.Int("attempt", attempt), zap.Error(err)) - } - time.Sleep(time.Duration(attempt) * 500 * time.Millisecond) - } - return lastErr -} - -func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container) *gin.Engine { - if !cfg.App.Debug { - gin.SetMode(gin.ReleaseMode) - } - r := gin.New() - r.Use(gin.Recovery()) - r.Use(middleware.RequestLogger(logger)) - if !cfg.App.Debug && len(cfg.App.CORSOrigins) == 0 { - logger.Warn("CORS: no origins configured in production — CORS headers will be omitted (same-origin enforced). Set app.cors_origins for cross-origin access.") - } - r.Use(middleware.CORS(cfg.App.CORSOrigins, cfg.App.Debug)) - - handler.Register(r, cfg, logger, svc) - - // Static SPA fallback. - if cfg.App.WebDir != "" { - serveSPA(r, cfg.App.WebDir) - } - return r -} - -// serveSPA serves the React build artifacts and falls back to index.html for -// non-API, non-asset paths so client-side routing keeps working. -func serveSPA(r *gin.Engine, webDir string) { - assets := r.Group("/assets") - assets.Use(func(c *gin.Context) { - c.Header("Cache-Control", "public, max-age=31536000, immutable") - c.Next() - }) - assets.Static("/", filepath.Join(webDir, "assets")) - brand := r.Group("/brand") - brand.Use(func(c *gin.Context) { - setNoCacheHeaders(c) - c.Next() - }) - brand.Static("/", filepath.Join(webDir, "brand")) - for _, rootFile := range []string{"/favicon.ico", "/favicon.svg", "/artwork-cache-sw.js"} { - filePath := filepath.Join(webDir, strings.TrimPrefix(rootFile, "/")) - r.GET(rootFile, serveNoCacheFile(filePath)) - r.HEAD(rootFile, serveNoCacheFile(filePath)) - } - r.NoRoute(func(c *gin.Context) { - path := c.Request.URL.Path - // Do not swallow API / Emby compatibility routes; clients expect JSON - // or 404, not the React index.html fallback. - if shouldBypassSPAFallback(path) { - c.Status(http.StatusNotFound) - return - } - serveSPAIndex(c, filepath.Join(webDir, "index.html")) - }) -} - -func serveNoCacheFile(filePath string) gin.HandlerFunc { - return func(c *gin.Context) { - setNoCacheHeaders(c) - if _, err := os.Stat(filePath); err != nil { - c.Status(http.StatusNotFound) - return - } - c.File(filePath) - } -} - -func serveSPAIndex(c *gin.Context, indexPath string) { - setNoCacheHeaders(c) - if _, err := os.Stat(indexPath); err != nil { - c.String(http.StatusNotFound, "MediaStationGo web UI not found: %s", indexPath) - return - } - c.File(indexPath) -} - -func setNoCacheHeaders(c *gin.Context) { - c.Header("Cache-Control", "no-cache, no-store, must-revalidate") - c.Header("Pragma", "no-cache") - c.Header("Expires", "0") -} - -func shouldBypassSPAFallback(path string) bool { - if isFrontendLibraryRoute(path) { - return false - } - lower := strings.ToLower(path) - for _, exact := range []string{ - "/emby", - } { - if lower == exact { - return true - } - } - for _, prefix := range []string{ - "/api/", - "/emby/", - "/system/", - "/users/", - "/items/", - "/shows/", - "/library/", - "/videos/", - "/sessions/", - "/displaypreferences/", - "/branding/", - "/localization/", - "/startup/", - "/quickconnect/", - "/socket", - "/embywebsocket", - } { - if strings.HasPrefix(lower, prefix) { - return true - } - } - return false -} - -func isFrontendLibraryRoute(path string) bool { - const prefix = "/library/" - if !strings.HasPrefix(path, prefix) { - return false - } - id := strings.TrimPrefix(path, prefix) - if strings.Contains(id, "/") { - return false - } - if len(id) != 36 { - return false - } - for i, ch := range id { - switch i { - case 8, 13, 18, 23: - if ch != '-' { - return false - } - default: - if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F')) { - return false - } - } - } - return true -} - -// newLogger 根据 cfg.Logging 构建 Zap。 -func newLogger(cfg *config.Config) (*zap.Logger, error) { - if cfg.App.Debug { - return zap.NewDevelopment() - } - level := configuredLogLevel(cfg.Logging.Level) - encoderCfg := zap.NewProductionEncoderConfig() - encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder - var encoder zapcore.Encoder - if strings.EqualFold(strings.TrimSpace(cfg.Logging.Format), "console") { - encoder = zapcore.NewConsoleEncoder(encoderCfg) - } else { - encoder = zapcore.NewJSONEncoder(encoderCfg) - } - cores := []zapcore.Core{ - zapcore.NewCore(encoder, zapcore.Lock(os.Stdout), level), - } - appPath, warnPath, errorPath := logFilePaths(cfg) - if appPath != "" { - appWriter, err := newRotatingFileWriter(appPath, cfg.Logging) - if err != nil { - return nil, err - } - cores = append(cores, zapcore.NewCore(encoder, appWriter, level)) - } - if warnPath != "" { - warnWriter, err := newRotatingFileWriter(warnPath, cfg.Logging) - if err != nil { - return nil, err - } - cores = append(cores, zapcore.NewCore(encoder, warnWriter, zap.LevelEnablerFunc(func(lvl zapcore.Level) bool { - return lvl == zapcore.WarnLevel && level.Enabled(lvl) - }))) - } - if errorPath != "" { - errorWriter, err := newRotatingFileWriter(errorPath, cfg.Logging) - if err != nil { - return nil, err - } - cores = append(cores, zapcore.NewCore(encoder, errorWriter, zap.LevelEnablerFunc(func(lvl zapcore.Level) bool { - return lvl >= zapcore.ErrorLevel && level.Enabled(lvl) - }))) - } - return zap.New(zapcore.NewTee(cores...), zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel), zap.ErrorOutput(zapcore.Lock(os.Stderr))), nil -} - -func configuredLogLevel(raw string) zapcore.Level { - level := zapcore.WarnLevel - raw = strings.TrimSpace(raw) - if raw != "" { - var parsed zapcore.Level - if err := parsed.UnmarshalText([]byte(raw)); err == nil { - level = parsed - } - } - return level -} - -func logFilePaths(cfg *config.Config) (string, string, string) { - out := strings.TrimSpace(cfg.Logging.OutputPath) - if strings.EqualFold(out, "stdout") || strings.EqualFold(out, "stderr") { - return "", "", "" - } - if out == "" { - out = filepath.Join(cfg.App.DataDir, "logs") - } - if ext := filepath.Ext(out); ext != "" { - base := strings.TrimSuffix(out, ext) - return out, base + ".warn" + ext, base + ".error" + ext - } - return filepath.Join(out, "app.log"), filepath.Join(out, "warn.log"), filepath.Join(out, "error.log") -} - -// getLocalIP returns the first non-loopback IPv4 address of the machine. -// Falls back to "localhost" if no suitable interface is found. -func getLocalIP() string { - interfaces, err := net.Interfaces() - if err != nil { - return "localhost" - } - for _, iface := range interfaces { - if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { - continue - } - addrs, err := iface.Addrs() - if err != nil { - continue - } - for _, addr := range addrs { - switch v := addr.(type) { - case *net.IPNet: - if ip := v.IP.To4(); ip != nil { - return ip.String() - } - } - } - } - return "localhost" -} - -// getPublicIP tries to detect the public-facing IP by querying ipify.org. -// Returns empty string if detection fails (e.g. no internet, timeout). -func getPublicIP(timeout time.Duration) string { - client := &http.Client{Timeout: timeout} - resp, err := client.Get("https://api.ipify.org") - if err != nil { - return "" - } - defer resp.Body.Close() - buf := make([]byte, 64) - n, err := resp.Body.Read(buf) - if err != nil || n == 0 || resp.StatusCode != http.StatusOK { - return "" - } - return string(buf[:n]) -} diff --git a/cmd/server/network.go b/cmd/server/network.go new file mode 100644 index 0000000..2b78ebe --- /dev/null +++ b/cmd/server/network.go @@ -0,0 +1,51 @@ +package main + +import ( + "net" + "net/http" + "time" +) + +// getLocalIP returns the first non-loopback IPv4 address of the machine. +// Falls back to "localhost" if no suitable interface is found. +func getLocalIP() string { + interfaces, err := net.Interfaces() + if err != nil { + return "localhost" + } + for _, iface := range interfaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + addrs, err := iface.Addrs() + if err != nil { + continue + } + for _, addr := range addrs { + switch v := addr.(type) { + case *net.IPNet: + if ip := v.IP.To4(); ip != nil { + return ip.String() + } + } + } + } + return "localhost" +} + +// getPublicIP tries to detect the public-facing IP by querying ipify.org. +// Returns empty string if detection fails (e.g. no internet, timeout). +func getPublicIP(timeout time.Duration) string { + client := &http.Client{Timeout: timeout} + resp, err := client.Get("https://api.ipify.org") + if err != nil { + return "" + } + defer resp.Body.Close() + buf := make([]byte, 64) + n, err := resp.Body.Read(buf) + if err != nil || n == 0 || resp.StatusCode != http.StatusOK { + return "" + } + return string(buf[:n]) +} diff --git a/cmd/server/router.go b/cmd/server/router.go new file mode 100644 index 0000000..0588e9f --- /dev/null +++ b/cmd/server/router.go @@ -0,0 +1,156 @@ +package main + +import ( + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/handler" + "github.com/ShukeBta/MediaStationGo/internal/middleware" + "github.com/ShukeBta/MediaStationGo/internal/service" +) + +func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container) *gin.Engine { + if !cfg.App.Debug { + gin.SetMode(gin.ReleaseMode) + } + r := gin.New() + r.Use(gin.Recovery()) + r.Use(middleware.RequestLogger(logger)) + if !cfg.App.Debug && len(cfg.App.CORSOrigins) == 0 { + logger.Warn("CORS: no origins configured in production — CORS headers will be omitted (same-origin enforced). Set app.cors_origins for cross-origin access.") + } + r.Use(middleware.CORS(cfg.App.CORSOrigins, cfg.App.Debug)) + + handler.Register(r, cfg, logger, svc) + + if cfg.App.WebDir != "" { + serveSPA(r, cfg.App.WebDir) + } + return r +} + +// serveSPA serves the React build artifacts and falls back to index.html for +// non-API, non-asset paths so client-side routing keeps working. +func serveSPA(r *gin.Engine, webDir string) { + assets := r.Group("/assets") + assets.Use(func(c *gin.Context) { + c.Header("Cache-Control", "public, max-age=31536000, immutable") + c.Next() + }) + assets.Static("/", filepath.Join(webDir, "assets")) + brand := r.Group("/brand") + brand.Use(func(c *gin.Context) { + setNoCacheHeaders(c) + c.Next() + }) + brand.Static("/", filepath.Join(webDir, "brand")) + for _, rootFile := range []string{"/favicon.ico", "/favicon.svg", "/artwork-cache-sw.js"} { + filePath := filepath.Join(webDir, strings.TrimPrefix(rootFile, "/")) + r.GET(rootFile, serveNoCacheFile(filePath)) + r.HEAD(rootFile, serveNoCacheFile(filePath)) + } + r.NoRoute(func(c *gin.Context) { + path := c.Request.URL.Path + if shouldBypassSPAFallback(path) { + c.Status(http.StatusNotFound) + return + } + serveSPAIndex(c, filepath.Join(webDir, "index.html")) + }) +} + +func serveNoCacheFile(filePath string) gin.HandlerFunc { + return func(c *gin.Context) { + setNoCacheHeaders(c) + if _, err := os.Stat(filePath); err != nil { + c.Status(http.StatusNotFound) + return + } + c.File(filePath) + } +} + +func serveSPAIndex(c *gin.Context, indexPath string) { + setNoCacheHeaders(c) + if _, err := os.Stat(indexPath); err != nil { + c.String(http.StatusNotFound, "MediaStationGo web UI not found: %s", indexPath) + return + } + c.File(indexPath) +} + +func setNoCacheHeaders(c *gin.Context) { + c.Header("Cache-Control", "no-cache, no-store, must-revalidate") + c.Header("Pragma", "no-cache") + c.Header("Expires", "0") +} + +func shouldBypassSPAFallback(path string) bool { + if isFrontendLibraryRoute(path) { + return false + } + lower := strings.ToLower(path) + for _, exact := range []string{ + "/emby", + } { + if lower == exact { + return true + } + } + for _, prefix := range []string{ + "/api/", + "/emby/", + "/system/", + "/users/", + "/items/", + "/shows/", + "/library/", + "/videos/", + "/sessions/", + "/displaypreferences/", + "/branding/", + "/localization/", + "/startup/", + "/quickconnect/", + "/socket", + "/embywebsocket", + } { + if strings.HasPrefix(lower, prefix) { + return true + } + } + return false +} + +func isFrontendLibraryRoute(path string) bool { + const prefix = "/library/" + if !strings.HasPrefix(path, prefix) { + return false + } + id := strings.TrimPrefix(path, prefix) + if strings.Contains(id, "/") { + return false + } + if len(id) != 36 { + return false + } + for i, ch := range id { + switch i { + case 8, 13, 18, 23: + if ch != '-' { + return false + } + default: + if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F')) { + return false + } + } + } + return true +} diff --git a/cmd/server/startup.go b/cmd/server/startup.go new file mode 100644 index 0000000..c5f415d --- /dev/null +++ b/cmd/server/startup.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "database/sql" + "runtime" + "time" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/config" +) + +func applyCPUThreadLimit(cfg *config.Config, logger *zap.Logger) { + if cfg == nil || cfg.App.MaxCPUThreads < 1 { + return + } + prev := runtime.GOMAXPROCS(cfg.App.MaxCPUThreads) + if logger != nil { + logger.Info("runtime CPU thread limit applied", + zap.Int("max_cpu_threads", cfg.App.MaxCPUThreads), + zap.Int("previous", prev)) + } +} + +func waitForDatabase(db interface{ DB() (*sql.DB, error) }, logger *zap.Logger) error { + sqlDB, err := db.DB() + if err != nil { + return err + } + var lastErr error + for attempt := 1; attempt <= 30; attempt++ { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + err = sqlDB.PingContext(ctx) + cancel() + if err == nil { + return nil + } + lastErr = err + if logger != nil { + logger.Warn("database not ready; retrying", zap.Int("attempt", attempt), zap.Error(err)) + } + time.Sleep(time.Duration(attempt) * 500 * time.Millisecond) + } + return lastErr +}