mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91c6d9dfd9 | |||
| 95d8126d65 | |||
| 3aa8662c45 |
@@ -115,7 +115,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: Install
|
||||
@@ -196,8 +196,12 @@ jobs:
|
||||
path: web/dist
|
||||
- name: Build binary
|
||||
run: |
|
||||
LDFLAGS="-s -w -X main.version=${{ needs.build-image.outputs.release_tag }}"
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
LDFLAGS="$LDFLAGS -H=windowsgui"
|
||||
fi
|
||||
CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \
|
||||
go build -trimpath -ldflags="-s -w -X main.version=${{ needs.build-image.outputs.release_tag }}" \
|
||||
go build -trimpath -ldflags="$LDFLAGS" \
|
||||
-o "dist/mebox-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" ./cmd/server
|
||||
- name: Package
|
||||
run: |
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
# before the Go toolchain touches the web package.
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: Build SPA
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
- name: Build linux/arm64
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server
|
||||
- name: Build windows/amd64
|
||||
run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server
|
||||
run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -130,4 +130,4 @@ jobs:
|
||||
build-args: |
|
||||
VERSION=${{ steps.version.outputs.full_version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
# before the Go toolchain touches the `web` package.
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: Build SPA
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
# =============================================================================
|
||||
# Multi-architecture build for MeBox.
|
||||
#
|
||||
# Stage 1 (frontend) : Node 20 -> static SPA bundle
|
||||
# Stage 1 (frontend) : Node 20.19+ -> static SPA bundle
|
||||
# Stage 2 (backend) : Go 1.25 -> single static binary (CGO_ENABLED=0)
|
||||
# Stage 3 (runtime) : Alpine 3.23 -> ffmpeg + tzdata + non-root user
|
||||
#
|
||||
@@ -15,7 +15,7 @@
|
||||
# =============================================================================
|
||||
|
||||
# ---- Stage 1: frontend (always build on the host architecture) -------------
|
||||
FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend
|
||||
FROM --platform=$BUILDPLATFORM node:20.19-alpine AS frontend
|
||||
ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/
|
||||
WORKDIR /app/web
|
||||
COPY web/package*.json ./
|
||||
|
||||
@@ -199,6 +199,11 @@ environment:
|
||||
**硬链接失败(cross-device link)?**
|
||||
硬链接要求源与目标在同一文件系统/子卷;跨盘、跨 btrfs 子卷或网盘挂载时请改用复制或软链接。
|
||||
|
||||
**日志保留时间太短?**
|
||||
|
||||
默认应用日志为 `20MB x 5`,容器 stdout 日志为 `20m x 3`。排障时可在 compose 中调大
|
||||
`MEBOX_LOGGING_MAX_SIZE_MB`、`MEBOX_LOGGING_MAX_BACKUPS` 与服务的 `logging.options.max-size/max-file`。
|
||||
|
||||
**第三方播放器连不上?**
|
||||
确认地址为 `http://IP:18080`,使用 MeBox 用户账号;反代部署需正确配置外部 URL 与 HTTPS 头。
|
||||
|
||||
@@ -207,6 +212,7 @@ environment:
|
||||
## 开发构建
|
||||
|
||||
后端通过 `go:embed` 嵌入 `web/dist`,**编译前必须先构建前端**。
|
||||
前端构建要求 Node.js `20.19+` 或 `22.12+`。
|
||||
|
||||
```bash
|
||||
npm --prefix web ci
|
||||
@@ -219,6 +225,14 @@ npm --prefix web run dev # http://127.0.0.1:3000
|
||||
|
||||
CI 会在 Release 中提供 Windows / Linux / macOS 的 amd64、arm64 单文件可执行程序。
|
||||
|
||||
Windows 本地打包:
|
||||
|
||||
```powershell
|
||||
.\scripts\build-windows.ps1 -Version dev
|
||||
```
|
||||
|
||||
Windows 可执行程序使用项目 Logo,不显示控制台窗口;启动后会常驻系统托盘。托盘菜单可打开 MeBox、切换开机自启、查看日志、重启或退出。
|
||||
|
||||
---
|
||||
|
||||
## 鸣谢
|
||||
|
||||
@@ -199,6 +199,14 @@ npm --prefix web run dev
|
||||
|
||||
Release builds ship single-file binaries for Windows, Linux, and macOS on amd64 and arm64.
|
||||
|
||||
Build the Windows executable locally:
|
||||
|
||||
```powershell
|
||||
.\scripts\build-windows.ps1 -Version dev
|
||||
```
|
||||
|
||||
The Windows executable uses the project logo and runs without a console window. It stays in the notification area, with menu actions for opening MeBox, toggling auto-start, viewing logs, restarting, and exiting.
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/database"
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// application owns the long-running MeBox server and all resources created
|
||||
// during startup. Platform entry points decide how the application is
|
||||
// controlled: a console signal loop or a Windows notification-area icon.
|
||||
type application struct {
|
||||
cfg *config.Config
|
||||
logger *zap.Logger
|
||||
embyCompatLogger *zap.Logger
|
||||
serverManager *serverManager
|
||||
services *service.Container
|
||||
|
||||
closeMu sync.Mutex
|
||||
closeFuncs []func()
|
||||
shutdown sync.Once
|
||||
shutdownErr error
|
||||
}
|
||||
|
||||
func newApplication() (*application, error) {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config load failed: %w", err)
|
||||
}
|
||||
|
||||
logger, closeLogger, err := newLoggerWithCloser(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("logger init failed: %w", err)
|
||||
}
|
||||
|
||||
app := &application{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
closeFuncs: []func(){closeLogger},
|
||||
}
|
||||
if err := app.start(); err != nil {
|
||||
app.closeLoggers()
|
||||
return nil, err
|
||||
}
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (a *application) start() error {
|
||||
embyCompatLogger, closeEmbyCompatLogger, err := newEmbyCompatLogger(a.cfg)
|
||||
if err != nil {
|
||||
a.logger.Error("Emby compatibility logger init failed", zap.Error(err))
|
||||
return fmt.Errorf("Emby compatibility logger init failed: %w", err)
|
||||
}
|
||||
a.embyCompatLogger = embyCompatLogger
|
||||
a.addCloser(closeEmbyCompatLogger)
|
||||
|
||||
appVersion := effectiveVersion(version)
|
||||
a.logger.Info("starting MeBox",
|
||||
zap.String("version", appVersion),
|
||||
zap.Int("port", a.cfg.App.Port),
|
||||
zap.String("data_dir", a.cfg.App.DataDir),
|
||||
zap.String("emby_compat_log", embyCompatLogPath(a.cfg)),
|
||||
)
|
||||
|
||||
for _, dir := range []string{a.cfg.App.DataDir, a.cfg.Cache.CacheDir} {
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
a.logger.Error("create dir failed", zap.String("dir", dir), zap.Error(err))
|
||||
return fmt.Errorf("create dir %s failed: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
db, err := database.Open(a.cfg, a.logger)
|
||||
if err != nil {
|
||||
a.logger.Error("database open failed", zap.Error(err))
|
||||
return fmt.Errorf("database open failed: %w", err)
|
||||
}
|
||||
if err := waitForDatabase(db, a.logger); err != nil {
|
||||
a.logger.Error("database not ready", zap.Error(err))
|
||||
return fmt.Errorf("database not ready: %w", err)
|
||||
}
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
a.logger.Error("auto-migrate failed", zap.Error(err))
|
||||
return fmt.Errorf("auto-migrate failed: %w", err)
|
||||
}
|
||||
if err := database.MigrateSQLiteToCurrentIfNeeded(a.cfg, db, a.logger); err != nil {
|
||||
a.logger.Error("sqlite to postgres migration failed", zap.Error(err))
|
||||
return fmt.Errorf("sqlite to postgres migration failed: %w", err)
|
||||
}
|
||||
|
||||
repos := repository.New(db)
|
||||
service.ApplyRuntimeSettings(context.Background(), a.cfg, repos, a.logger)
|
||||
applyCPUThreadLimit(a.cfg, a.logger)
|
||||
a.services = service.NewWithVersion(a.cfg, a.logger, repos, appVersion)
|
||||
|
||||
// 一次性清洗历史脏数据: 老版本把单集 episode id / 单集名写进整剧字段, 导致
|
||||
// 同一部剧被拆成多张单集卡。清空被污染的字段并重置为 pending(借后续重刮修正)。
|
||||
if cleaned, err := a.services.NormalizePollutedEpisodeMetadata(context.Background()); err != nil {
|
||||
a.logger.Warn("polluted episode metadata cleanup failed", zap.Error(err))
|
||||
} else if cleaned > 0 {
|
||||
a.logger.Info("polluted episode metadata cleanup completed", zap.Int("media_count", cleaned))
|
||||
}
|
||||
|
||||
if err := a.services.Auth.SeedAdmin(context.Background()); err != nil {
|
||||
a.logger.Warn("seed admin failed", zap.Error(err))
|
||||
}
|
||||
|
||||
router := buildRouter(a.cfg, a.logger, a.embyCompatLogger, a.services)
|
||||
a.serverManager = newServerManager(a.cfg, a.logger, router)
|
||||
a.services.ReloadHTTPServer = a.serverManager.Reload
|
||||
if err := a.serverManager.Start(); err != nil {
|
||||
a.logger.Error("listen failed", zap.Error(err))
|
||||
return fmt.Errorf("listen failed: %w", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
scheme := "http"
|
||||
if a.cfg.App.HTTPSEnabled {
|
||||
scheme = "https"
|
||||
}
|
||||
if publicIP := getPublicIP(3 * time.Second); publicIP != "" {
|
||||
a.logger.Info("server public endpoint",
|
||||
zap.String("public", fmt.Sprintf("%s://%s:%d", scheme, publicIP, a.cfg.App.Port)),
|
||||
)
|
||||
}
|
||||
}()
|
||||
helper.Go(a.logger, "services.boot", a.services.Boot)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown is idempotent and safe to call from both the tray handler and the
|
||||
// systray exit callback.
|
||||
func (a *application) Shutdown() error {
|
||||
a.shutdown.Do(func() {
|
||||
a.logger.Info("shutdown requested")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
if a.serverManager != nil {
|
||||
if err := a.serverManager.Shutdown(ctx); err != nil {
|
||||
a.shutdownErr = err
|
||||
a.logger.Error("graceful shutdown failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
|
||||
if a.services != nil {
|
||||
a.services.Close()
|
||||
}
|
||||
a.logger.Info("MeBox stopped")
|
||||
_ = a.logger.Sync()
|
||||
if a.embyCompatLogger != nil {
|
||||
_ = a.embyCompatLogger.Sync()
|
||||
}
|
||||
a.closeLoggers()
|
||||
})
|
||||
return a.shutdownErr
|
||||
}
|
||||
|
||||
func (a *application) addCloser(fn func()) {
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
a.closeMu.Lock()
|
||||
a.closeFuncs = append(a.closeFuncs, fn)
|
||||
a.closeMu.Unlock()
|
||||
}
|
||||
|
||||
func (a *application) closeLoggers() {
|
||||
a.closeMu.Lock()
|
||||
closers := append([]func(){}, a.closeFuncs...)
|
||||
a.closeFuncs = nil
|
||||
a.closeMu.Unlock()
|
||||
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
closers[i]()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *application) localURL() string {
|
||||
config.RuntimeMu.RLock()
|
||||
scheme := "http"
|
||||
if a.cfg.App.HTTPSEnabled {
|
||||
scheme = "https"
|
||||
}
|
||||
port := a.cfg.App.Port
|
||||
config.RuntimeMu.RUnlock()
|
||||
return fmt.Sprintf("%s://127.0.0.1:%d", scheme, port)
|
||||
}
|
||||
+1
-114
@@ -11,21 +11,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/database"
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// version is overwritten at build time via -ldflags="-X main.version=...".
|
||||
@@ -46,105 +33,5 @@ func effectiveVersion(buildVersion string) string {
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "config load failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logger, err := newLogger(cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "logger init failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = logger.Sync() }()
|
||||
|
||||
embyCompatLogger, closeEmbyCompatLogger, err := newEmbyCompatLogger(cfg)
|
||||
if err != nil {
|
||||
logger.Fatal("Emby compatibility logger init failed", zap.Error(err))
|
||||
}
|
||||
defer func() {
|
||||
_ = embyCompatLogger.Sync()
|
||||
closeEmbyCompatLogger()
|
||||
}()
|
||||
|
||||
appVersion := effectiveVersion(version)
|
||||
logger.Info("starting MeBox",
|
||||
zap.String("version", appVersion),
|
||||
zap.Int("port", cfg.App.Port),
|
||||
zap.String("data_dir", cfg.App.DataDir),
|
||||
zap.String("emby_compat_log", embyCompatLogPath(cfg)),
|
||||
)
|
||||
|
||||
// Ensure data / cache / web dirs exist.
|
||||
for _, d := range []string{cfg.App.DataDir, cfg.Cache.CacheDir} {
|
||||
if err := os.MkdirAll(d, 0o750); err != nil {
|
||||
logger.Fatal("create dir failed", zap.String("dir", d), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
db, err := database.Open(cfg, logger)
|
||||
if err != nil {
|
||||
logger.Fatal("database open failed", zap.Error(err))
|
||||
}
|
||||
if err := waitForDatabase(db, logger); err != nil {
|
||||
logger.Fatal("database not ready", zap.Error(err))
|
||||
}
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
logger.Fatal("auto-migrate failed", zap.Error(err))
|
||||
}
|
||||
if err := database.MigrateSQLiteToCurrentIfNeeded(cfg, db, logger); err != nil {
|
||||
logger.Fatal("sqlite to postgres migration failed", zap.Error(err))
|
||||
}
|
||||
|
||||
repos := repository.New(db)
|
||||
service.ApplyRuntimeSettings(context.Background(), cfg, repos, logger)
|
||||
applyCPUThreadLimit(cfg, logger)
|
||||
services := service.NewWithVersion(cfg, logger, repos, appVersion)
|
||||
|
||||
// 一次性清洗历史脏数据: 老版本把单集 episode id / 单集名写进整剧字段, 导致
|
||||
// 同一部剧被拆成多张单集卡。清空被污染的字段并重置为 pending(借后续重刮修正)。
|
||||
if cleaned, err := services.NormalizePollutedEpisodeMetadata(context.Background()); err != nil {
|
||||
logger.Warn("polluted episode metadata cleanup failed", zap.Error(err))
|
||||
} else if cleaned > 0 {
|
||||
logger.Info("polluted episode metadata cleanup completed", zap.Int("media_count", cleaned))
|
||||
}
|
||||
|
||||
if err := services.Auth.SeedAdmin(context.Background()); err != nil {
|
||||
logger.Warn("seed admin failed", zap.Error(err))
|
||||
}
|
||||
|
||||
router := buildRouter(cfg, logger, embyCompatLogger, services)
|
||||
|
||||
serverMgr := newServerManager(cfg, logger, router)
|
||||
services.ReloadHTTPServer = serverMgr.Reload
|
||||
if err := serverMgr.Start(); err != nil {
|
||||
logger.Fatal("listen failed", zap.Error(err))
|
||||
}
|
||||
go func() {
|
||||
scheme := "http"
|
||||
if cfg.App.HTTPSEnabled {
|
||||
scheme = "https"
|
||||
}
|
||||
if publicIP := getPublicIP(3 * time.Second); publicIP != "" {
|
||||
logger.Info("server public endpoint",
|
||||
zap.String("public", fmt.Sprintf("%s://%s:%d", scheme, publicIP, cfg.App.Port)),
|
||||
)
|
||||
}
|
||||
}()
|
||||
helper.Go(logger, "services.boot", services.Boot)
|
||||
|
||||
// Graceful shutdown.
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-stop
|
||||
logger.Info("shutdown requested")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := serverMgr.Shutdown(ctx); err != nil {
|
||||
logger.Error("graceful shutdown failed", zap.Error(err))
|
||||
}
|
||||
services.Close()
|
||||
logger.Info("MeBox stopped")
|
||||
runProgram()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func runProgram() {
|
||||
app, err := newApplication()
|
||||
if err != nil {
|
||||
reportError("MeBox 启动失败", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-stop
|
||||
|
||||
if err := app.Shutdown(); err != nil {
|
||||
reportError("MeBox 退出失败", err)
|
||||
}
|
||||
}
|
||||
|
||||
func reportError(title string, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "%s: %v\n", title, err)
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
|
||||
"fyne.io/systray"
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/brand"
|
||||
)
|
||||
|
||||
const (
|
||||
runRegistryKey = `Software\Microsoft\Windows\CurrentVersion\Run`
|
||||
runRegistryValue = "MeBox"
|
||||
singleInstanceName = `Local\MeBox-Server`
|
||||
)
|
||||
|
||||
var errAlreadyRunning = errors.New("MeBox is already running")
|
||||
|
||||
func runProgram() {
|
||||
if err := prepareWorkingDirectory(); err != nil {
|
||||
reportError("MeBox 启动失败", fmt.Errorf("切换工作目录失败: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
instance, err := acquireSingleInstance()
|
||||
if errors.Is(err, errAlreadyRunning) {
|
||||
reportError("MeBox", errors.New("MeBox 已在运行,请查看右下角托盘图标"))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
reportError("MeBox 启动失败", fmt.Errorf("创建单实例锁失败: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
app, err := newApplication()
|
||||
if err != nil {
|
||||
_ = instance.Close()
|
||||
reportError("MeBox 启动失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
controller := &trayController{app: app}
|
||||
systray.Run(controller.onReady, controller.onExit)
|
||||
|
||||
// Release the mutex before starting the replacement process. The new
|
||||
// process must be able to acquire it immediately after the old one exits.
|
||||
_ = instance.Close()
|
||||
|
||||
if !controller.readyClosed.Load() {
|
||||
_ = app.Shutdown()
|
||||
reportError("MeBox 启动失败", errors.New("系统托盘初始化失败"))
|
||||
return
|
||||
}
|
||||
if controller.restartRequested.Load() {
|
||||
if err := launchSelf(); err != nil {
|
||||
reportError("MeBox 重启失败", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type trayController struct {
|
||||
app *application
|
||||
|
||||
readyClosed atomic.Bool
|
||||
restartRequested atomic.Bool
|
||||
shutdownStarted atomic.Bool
|
||||
}
|
||||
|
||||
func (c *trayController) onReady() {
|
||||
defer func() {
|
||||
c.readyClosed.Store(true)
|
||||
}()
|
||||
|
||||
systray.SetIcon(brand.Icon)
|
||||
systray.SetTooltip("MeBox")
|
||||
|
||||
mOpen := systray.AddMenuItem("打开 MeBox", "在浏览器中打开 MeBox")
|
||||
mAutoStart := systray.AddMenuItemCheckbox("开机自启", "登录 Windows 后自动启动 MeBox", autoStartEnabled())
|
||||
mLogs := systray.AddMenuItem("查看日志", "打开 MeBox 应用日志")
|
||||
systray.AddSeparator()
|
||||
mRestart := systray.AddMenuItem("重启 MeBox", "重启 MeBox 服务")
|
||||
mQuit := systray.AddMenuItem("退出 MeBox", "停止服务并退出")
|
||||
|
||||
initialAutoStart := mAutoStart.Checked()
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-mOpen.ClickedCh:
|
||||
if err := openURL(c.app.localURL()); err != nil {
|
||||
reportError("MeBox", fmt.Errorf("打开 MeBox 失败: %w", err))
|
||||
}
|
||||
case <-mAutoStart.ClickedCh:
|
||||
enable := !mAutoStart.Checked()
|
||||
if err := setAutoStart(enable); err != nil {
|
||||
if initialAutoStart {
|
||||
mAutoStart.Check()
|
||||
} else {
|
||||
mAutoStart.Uncheck()
|
||||
}
|
||||
reportError("MeBox", fmt.Errorf("更新开机自启设置失败: %w", err))
|
||||
continue
|
||||
}
|
||||
if enable {
|
||||
mAutoStart.Check()
|
||||
} else {
|
||||
mAutoStart.Uncheck()
|
||||
}
|
||||
initialAutoStart = enable
|
||||
case <-mLogs.ClickedCh:
|
||||
if err := c.app.openLog(); err != nil {
|
||||
reportError("MeBox", fmt.Errorf("打开日志失败: %w", err))
|
||||
}
|
||||
case <-mRestart.ClickedCh:
|
||||
c.restart()
|
||||
case <-mQuit.ClickedCh:
|
||||
c.quit()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (c *trayController) onExit() {
|
||||
_ = c.app.Shutdown()
|
||||
}
|
||||
|
||||
func (c *trayController) quit() {
|
||||
if !c.shutdownStarted.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
_ = c.app.Shutdown()
|
||||
systray.Quit()
|
||||
}()
|
||||
}
|
||||
|
||||
func (c *trayController) restart() {
|
||||
if !c.shutdownStarted.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
c.restartRequested.Store(true)
|
||||
go func() {
|
||||
_ = c.app.Shutdown()
|
||||
systray.Quit()
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *application) openLog() error {
|
||||
appLog, _, _ := logFilePaths(a.cfg)
|
||||
if appLog != "" {
|
||||
if _, err := os.Stat(appLog); err == nil {
|
||||
return openPath(appLog)
|
||||
}
|
||||
_ = os.MkdirAll(filepath.Dir(appLog), 0o750)
|
||||
return openPath(filepath.Dir(appLog))
|
||||
}
|
||||
|
||||
logDir := filepath.Join(a.cfg.App.DataDir, "logs")
|
||||
if err := os.MkdirAll(logDir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
return openPath(logDir)
|
||||
}
|
||||
|
||||
func prepareWorkingDirectory() error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exeDir := filepath.Dir(exe)
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if samePath(exeDir, cwd) || looksLikeProjectDirectory(cwd) {
|
||||
return nil
|
||||
}
|
||||
return os.Chdir(exeDir)
|
||||
}
|
||||
|
||||
func looksLikeProjectDirectory(dir string) bool {
|
||||
for _, name := range []string{"go.mod", "config.yaml", "data", filepath.Join("web", "dist")} {
|
||||
if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func samePath(left, right string) bool {
|
||||
return strings.EqualFold(filepath.Clean(left), filepath.Clean(right))
|
||||
}
|
||||
|
||||
type singleInstance struct {
|
||||
handle windows.Handle
|
||||
}
|
||||
|
||||
func acquireSingleInstance() (*singleInstance, error) {
|
||||
name, err := windows.UTF16PtrFromString(singleInstanceName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handle, err := windows.CreateMutex(nil, false, name)
|
||||
if errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
|
||||
if handle != 0 {
|
||||
_ = windows.CloseHandle(handle)
|
||||
}
|
||||
return nil, errAlreadyRunning
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &singleInstance{handle: handle}, nil
|
||||
}
|
||||
|
||||
func (s *singleInstance) Close() error {
|
||||
if s == nil || s.handle == 0 {
|
||||
return nil
|
||||
}
|
||||
err := windows.CloseHandle(s.handle)
|
||||
s.handle = 0
|
||||
return err
|
||||
}
|
||||
|
||||
func launchSelf() error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command(exe, os.Args[1:]...)
|
||||
cmd.Dir = cwd
|
||||
cmd.Env = os.Environ()
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
CreationFlags: windows.CREATE_NEW_PROCESS_GROUP | windows.DETACHED_PROCESS,
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
return cmd.Process.Release()
|
||||
}
|
||||
|
||||
func autoStartEnabled() bool {
|
||||
key, err := registry.OpenKey(registry.CURRENT_USER, runRegistryKey, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer key.Close()
|
||||
|
||||
value, _, err := key.GetStringValue(runRegistryValue)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(strings.Trim(value, `"`)), filepath.Clean(exe))
|
||||
}
|
||||
|
||||
func setAutoStart(enabled bool) error {
|
||||
key, _, err := registry.CreateKey(registry.CURRENT_USER, runRegistryKey, registry.SET_VALUE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer key.Close()
|
||||
|
||||
if !enabled {
|
||||
if err := key.DeleteValue(runRegistryValue); err != nil && !errors.Is(err, registry.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return key.SetStringValue(runRegistryValue, syscall.EscapeArg(filepath.Clean(exe)))
|
||||
}
|
||||
|
||||
func openURL(url string) error {
|
||||
return shellOpen(url)
|
||||
}
|
||||
|
||||
func openPath(path string) error {
|
||||
return shellOpen(path)
|
||||
}
|
||||
|
||||
func shellOpen(target string) error {
|
||||
targetPtr, err := windows.UTF16PtrFromString(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verbPtr, err := windows.UTF16PtrFromString("open")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return windows.ShellExecute(0, verbPtr, targetPtr, nil, nil, 1)
|
||||
}
|
||||
|
||||
func reportError(title string, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
text, textErr := windows.UTF16PtrFromString(title + "\r\n\r\n" + err.Error())
|
||||
if textErr != nil {
|
||||
return
|
||||
}
|
||||
caption, captionErr := windows.UTF16PtrFromString("MeBox")
|
||||
if captionErr != nil {
|
||||
return
|
||||
}
|
||||
_, _ = windows.MessageBox(0, text, caption, windows.MB_OK|windows.MB_ICONERROR|windows.MB_SETFOREGROUND)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
// Regenerate the linked Windows resources after changing the project logo or
|
||||
// manifest:
|
||||
//
|
||||
// go generate ./cmd/server
|
||||
//
|
||||
//go:generate go run github.com/akavel/rsrc@v0.10.2 -arch amd64 -ico ../../internal/brand/logo.ico -manifest winres/mebox.manifest -o rsrc_windows_amd64.syso
|
||||
//go:generate go run github.com/akavel/rsrc@v0.10.2 -arch arm64 -ico ../../internal/brand/logo.ico -manifest winres/mebox.manifest -o rsrc_windows_arm64.syso
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<assemblyIdentity
|
||||
version="1.0.0.0"
|
||||
processorArchitecture="*"
|
||||
name="MeBox"
|
||||
type="win32"
|
||||
/>
|
||||
<description>MeBox media server</description>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
+10
-10
@@ -81,8 +81,8 @@ services:
|
||||
MEBOX_LOGGING_LEVEL: info
|
||||
MEBOX_LOGGING_FORMAT: console
|
||||
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
|
||||
MEBOX_LOGGING_MAX_SIZE_MB: "50"
|
||||
MEBOX_LOGGING_MAX_BACKUPS: "20"
|
||||
MEBOX_LOGGING_MAX_SIZE_MB: "20"
|
||||
MEBOX_LOGGING_MAX_BACKUPS: "5"
|
||||
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
|
||||
|
||||
MEBOX_DATABASE_TYPE: postgres
|
||||
@@ -124,8 +124,8 @@ services:
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
@@ -147,8 +147,8 @@ services:
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
@@ -172,8 +172,8 @@ services:
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
opensearch:
|
||||
image: opensearchproject/opensearch:2
|
||||
@@ -196,5 +196,5 @@ services:
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
@@ -64,8 +64,8 @@ services:
|
||||
MEBOX_LOGGING_LEVEL: info
|
||||
MEBOX_LOGGING_FORMAT: console
|
||||
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
|
||||
MEBOX_LOGGING_MAX_SIZE_MB: "50"
|
||||
MEBOX_LOGGING_MAX_BACKUPS: "20"
|
||||
MEBOX_LOGGING_MAX_SIZE_MB: "20"
|
||||
MEBOX_LOGGING_MAX_BACKUPS: "5"
|
||||
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
|
||||
|
||||
extra_hosts:
|
||||
@@ -82,5 +82,5 @@ services:
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
@@ -76,8 +76,8 @@ services:
|
||||
MEBOX_LOGGING_LEVEL: info
|
||||
MEBOX_LOGGING_FORMAT: console
|
||||
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
|
||||
MEBOX_LOGGING_MAX_SIZE_MB: "50"
|
||||
MEBOX_LOGGING_MAX_BACKUPS: "20"
|
||||
MEBOX_LOGGING_MAX_SIZE_MB: "20"
|
||||
MEBOX_LOGGING_MAX_BACKUPS: "5"
|
||||
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
|
||||
|
||||
MEBOX_DATABASE_TYPE: postgres
|
||||
@@ -113,8 +113,8 @@ services:
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
@@ -136,8 +136,8 @@ services:
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
@@ -161,5 +161,5 @@ services:
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
+6
-6
@@ -92,8 +92,8 @@ services:
|
||||
MEBOX_LOGGING_LEVEL: info
|
||||
MEBOX_LOGGING_FORMAT: console
|
||||
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
|
||||
MEBOX_LOGGING_MAX_SIZE_MB: "50"
|
||||
MEBOX_LOGGING_MAX_BACKUPS: "20"
|
||||
MEBOX_LOGGING_MAX_SIZE_MB: "20"
|
||||
MEBOX_LOGGING_MAX_BACKUPS: "5"
|
||||
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
|
||||
|
||||
# 轻量模式默认只使用 PostgreSQL,适合大多数 NAS。
|
||||
@@ -141,8 +141,8 @@ services:
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
@@ -166,5 +166,5 @@ services:
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
@@ -3,6 +3,7 @@ module github.com/truewhile/MeBox
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
fyne.io/systray v1.12.2
|
||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2
|
||||
github.com/fsnotify/fsnotify v1.7.0
|
||||
github.com/gin-contrib/gzip v1.2.6
|
||||
@@ -12,16 +13,16 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/metatube-community/metatube-sdk-go v1.4.0
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
github.com/redis/go-redis/v9 v9.7.3
|
||||
github.com/shirou/gopsutil/v3 v3.24.5
|
||||
github.com/spf13/viper v1.18.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/ulikunitz/xz v0.5.12
|
||||
github.com/ulikunitz/xz v0.5.15
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/image v0.37.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/image v0.45.0
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/time v0.15.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
@@ -48,10 +49,11 @@ require (
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.8.0 // indirect
|
||||
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
@@ -69,7 +71,7 @@ require (
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
@@ -89,7 +91,7 @@ require (
|
||||
golang.org/x/arch v0.25.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
|
||||
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2 h1:40yUSXwdkWN851BHCq6uiDhleh7A4+0yIBS+IUAqZVY=
|
||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
@@ -56,6 +58,8 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
@@ -77,8 +81,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
@@ -121,10 +125,10 @@ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
|
||||
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
@@ -171,8 +175,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
||||
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
@@ -192,28 +196,28 @@ golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtC
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
||||
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Package brand contains the embedded MeBox application artwork.
|
||||
package brand
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// Icon is the Windows ICO used by both the executable resource and the
|
||||
// notification-area icon.
|
||||
//
|
||||
//go:embed logo.ico
|
||||
var Icon []byte
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
@@ -127,5 +127,10 @@ func (z zapStdLogger) Printf(format string, args ...interface{}) {
|
||||
if z.log == nil {
|
||||
return
|
||||
}
|
||||
z.log.Sugar().Infof(format, args...)
|
||||
message := fmt.Sprintf(format, args...)
|
||||
if strings.Contains(strings.ToLower(message), "context canceled") {
|
||||
z.log.Debug(message)
|
||||
return
|
||||
}
|
||||
z.log.Info(message)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
||||
api.Use(middleware.GzipAPI())
|
||||
{
|
||||
api.GET("/health", healthCheck)
|
||||
api.HEAD("/health", healthCheck)
|
||||
api.GET("/version", versionInfo)
|
||||
api.GET("/public/ui-config", publicUIConfigHandler(svc))
|
||||
|
||||
|
||||
+20
-10
@@ -138,25 +138,35 @@ func testStrmAccountHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
acct.LastTestAt = &now
|
||||
if acct.Provider == model.StrmProviderEmbyRemote && svc.EmbyRemote != nil {
|
||||
result := ""
|
||||
ok := false
|
||||
if err := svc.EmbyRemote.TestConnection(c.Request.Context(), acct); err != nil {
|
||||
acct.LastTestResult = err.Error()
|
||||
acct.LastTestOK = false
|
||||
result = err.Error()
|
||||
} else {
|
||||
acct.LastTestResult = "ok"
|
||||
acct.LastTestOK = true
|
||||
result = "ok"
|
||||
ok = true
|
||||
}
|
||||
} else {
|
||||
acct = svc.Strm.TestStrmAccount(c.Request.Context(), id)
|
||||
if acct == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "网盘账号不存在"})
|
||||
acct.LastTestAt = &now
|
||||
acct.LastTestResult = result
|
||||
acct.LastTestOK = ok
|
||||
if err := svc.Repo.StrmAccount.UpdateTestResult(c.Request.Context(), acct.ID, now, result, ok); err != nil {
|
||||
// 写库失败时仍返回本地测试结果;不要回退到整行 Update,
|
||||
// 那会覆盖 TestConnection 期间可能刷新的账号配置。
|
||||
c.JSON(http.StatusOK, strmAccountViews(svc, []model.StrmAccount{*acct})[0])
|
||||
return
|
||||
}
|
||||
if fresh, err := svc.Repo.StrmAccount.FindByID(c.Request.Context(), acct.ID); err == nil && fresh != nil {
|
||||
acct = fresh
|
||||
}
|
||||
c.JSON(http.StatusOK, strmAccountViews(svc, []model.StrmAccount{*acct})[0])
|
||||
return
|
||||
}
|
||||
_ = svc.Repo.StrmAccount.Update(c.Request.Context(), acct)
|
||||
acct = svc.Strm.TestStrmAccount(c.Request.Context(), id)
|
||||
if acct == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "网盘账号不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, strmAccountViews(svc, []model.StrmAccount{*acct})[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,21 @@ func (r *StrmAccountRepository) Update(ctx context.Context, a *model.StrmAccount
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateTestResult updates only connectivity-test metadata. Callers that touch
|
||||
// account credentials must not use Update with a snapshot read before Ping:
|
||||
// a 115 token refresh can persist new tokens while Ping is running, and writing
|
||||
// the stale snapshot back would revoke the freshly rotated credentials.
|
||||
func (r *StrmAccountRepository) UpdateTestResult(ctx context.Context, id string, at time.Time, result string, ok bool) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Model(&model.StrmAccount{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"last_test_at": at,
|
||||
"last_test_result": result,
|
||||
"last_test_ok": ok,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *StrmAccountRepository) Delete(ctx context.Context, id string) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.StrmAccount{}).Error
|
||||
|
||||
@@ -1423,7 +1423,9 @@ func (s *DanmakuService) fetchCommentWithFallback(ctx context.Context, primary,
|
||||
}
|
||||
lastErr = err
|
||||
if i < len(bases)-1 {
|
||||
s.log.Warn("danmaku comment fetch failed on configured source, falling back to official", zap.String("source", base), zap.Error(err))
|
||||
s.log.Warn("danmaku comment fetch failed on configured source, falling back to official",
|
||||
zap.String("source", redactSensitiveURL(base)),
|
||||
zap.Error(redactSensitiveError(err)))
|
||||
}
|
||||
}
|
||||
return "", "auto", lastErr
|
||||
@@ -1439,7 +1441,9 @@ func (s *DanmakuService) searchCandidatesWithSource(ctx context.Context, configu
|
||||
if err == nil {
|
||||
return candidates, configured, nil
|
||||
}
|
||||
s.log.Warn("danmaku search failed on configured source, falling back to official", zap.String("source", configured), zap.Error(err))
|
||||
s.log.Warn("danmaku search failed on configured source, falling back to official",
|
||||
zap.String("source", redactSensitiveURL(configured)),
|
||||
zap.Error(redactSensitiveError(err)))
|
||||
}
|
||||
candidates, err := s.searchCandidates(ctx, official, name, episode)
|
||||
return candidates, official, err
|
||||
@@ -1573,9 +1577,9 @@ func (s *DanmakuService) lookupConfiguredEpisodes(ctx context.Context, configure
|
||||
candidates, err := s.searchCandidates(ctx, configured, m.AnimeTitle, episodeNum)
|
||||
if err != nil {
|
||||
s.log.Debug("danmaku configured lookup failed",
|
||||
zap.String("source", configured),
|
||||
zap.String("source", redactSensitiveURL(configured)),
|
||||
zap.String("anime", m.AnimeTitle),
|
||||
zap.Error(err))
|
||||
zap.Error(redactSensitiveError(err)))
|
||||
return nil, false
|
||||
}
|
||||
matched := matchDanmakuEpisodes(candidates, episodeNum, m.EpisodeTitle)
|
||||
|
||||
@@ -69,7 +69,8 @@ type EmbyService struct {
|
||||
// latestFlight collapses the homepage stampede: clients request Latest
|
||||
// for every library at once, and a shared expiry used to rebuild each
|
||||
// library in parallel.
|
||||
latestFlight singleflight.Group
|
||||
latestFlight singleflight.Group
|
||||
latestRefresh sync.Map
|
||||
|
||||
tmdb *TMDbProvider
|
||||
adult *AdultProvider
|
||||
|
||||
@@ -130,11 +130,27 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
|
||||
limit = 20
|
||||
}
|
||||
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
|
||||
if items, ok := e.cachedLatestItems(ctx, cacheKey); ok {
|
||||
if items, stale, ok := e.cachedLatestItemsWithStale(ctx, cacheKey); ok {
|
||||
if stale {
|
||||
e.refreshLatestItemsAsync(cacheKey, userID, parentID, limit)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
value, err := e.loadLatestItemsCached(ctx, userID, parentID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if value.Items == nil {
|
||||
return []map[string]any{}, nil
|
||||
}
|
||||
return value.Items, nil
|
||||
}
|
||||
|
||||
func (e *EmbyService) loadLatestItemsCached(ctx context.Context, userID, parentID string, limit int) (embyLatestCacheValue, error) {
|
||||
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
|
||||
// 一个客户端断开不应取消正在为其他客户端填充的共享重建。
|
||||
loadCtx := context.WithoutCancel(ctx)
|
||||
loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 45*time.Second)
|
||||
defer cancel()
|
||||
v, err, _ := e.latestFlight.Do(cacheKey, func() (any, error) {
|
||||
if items, ok := e.cachedLatestItems(loadCtx, cacheKey); ok {
|
||||
return embyLatestCacheValue{Items: items}, nil
|
||||
@@ -144,34 +160,55 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
|
||||
return nil, err
|
||||
}
|
||||
if e.cache != nil {
|
||||
e.cache.SetJSON(loadCtx, cacheKey, value, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second)
|
||||
freshTTL := time.Duration(e.embyLatestCacheTTLSeconds()) * time.Second
|
||||
e.cache.SetJSONWithStale(loadCtx, cacheKey, value, freshTTL, freshTTL+30*time.Minute)
|
||||
}
|
||||
e.rememberArtworkRefs(value.Artwork)
|
||||
return value, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return embyLatestCacheValue{}, err
|
||||
}
|
||||
cached, _ := v.(embyLatestCacheValue)
|
||||
if cached.Items == nil {
|
||||
return []map[string]any{}, nil
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
func (e *EmbyService) refreshLatestItemsAsync(cacheKey, userID, parentID string, limit int) {
|
||||
if e == nil || e.cache == nil {
|
||||
return
|
||||
}
|
||||
return cached.Items, nil
|
||||
if _, loaded := e.latestRefresh.LoadOrStore(cacheKey, struct{}{}); loaded {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer e.latestRefresh.Delete(cacheKey)
|
||||
if _, err := e.loadLatestItemsCached(context.Background(), userID, parentID, limit); err != nil && e.log != nil {
|
||||
e.log.Debug("background refresh of latest items failed",
|
||||
zap.String("parent_id", parentID),
|
||||
zap.Error(redactSensitiveError(err)))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (e *EmbyService) cachedLatestItems(ctx context.Context, cacheKey string) ([]map[string]any, bool) {
|
||||
items, stale, ok := e.cachedLatestItemsWithStale(ctx, cacheKey)
|
||||
return items, ok && !stale
|
||||
}
|
||||
|
||||
func (e *EmbyService) cachedLatestItemsWithStale(ctx context.Context, cacheKey string) ([]map[string]any, bool, bool) {
|
||||
if e == nil || e.cache == nil {
|
||||
return nil, false
|
||||
return nil, false, false
|
||||
}
|
||||
var cached embyLatestCacheValue
|
||||
if !e.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
return nil, false
|
||||
found, stale := e.cache.GetJSONStale(ctx, cacheKey, &cached)
|
||||
if !found {
|
||||
return nil, false, false
|
||||
}
|
||||
e.rememberArtworkRefs(cached.Artwork)
|
||||
if cached.Items == nil {
|
||||
return []map[string]any{}, true
|
||||
return []map[string]any{}, stale, true
|
||||
}
|
||||
return cached.Items, true
|
||||
return cached.Items, stale, true
|
||||
}
|
||||
|
||||
func (e *EmbyService) loadLatestItems(ctx context.Context, userID, parentID string, limit int) (embyLatestCacheValue, error) {
|
||||
|
||||
@@ -510,12 +510,12 @@ func (r *EmbyRemoteService) ensureTokenOnLine(ctx context.Context, acct *model.S
|
||||
req.Header.Set("X-Emby-Authorization", `MediaBrowser Client="MeBox", Device="MeBox-Federated", DeviceId="mebox-federated", Version="1.0"`)
|
||||
resp, err := r.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接远程 Emby 失败: %w", err)
|
||||
return redactSensitiveError(fmt.Errorf("连接远程 Emby 失败: %w", err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("远程 Emby 登录失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
|
||||
return redactSensitiveError(fmt.Errorf("远程 Emby 登录失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data))))
|
||||
}
|
||||
var login struct {
|
||||
AccessToken string `json:"AccessToken"`
|
||||
@@ -599,8 +599,16 @@ func (r *EmbyRemoteService) doGet(ctx context.Context, acct *model.StrmAccount,
|
||||
}
|
||||
if lastErr != nil {
|
||||
if r.log != nil && acct != nil {
|
||||
r.log.Warn("remote emby request failed",
|
||||
zap.String("account", acct.Name), zap.String("path", path), zap.Error(lastErr))
|
||||
fields := []zap.Field{
|
||||
zap.String("account", acct.Name),
|
||||
zap.String("path", path),
|
||||
zap.Error(redactSensitiveError(lastErr)),
|
||||
}
|
||||
if errors.Is(lastErr, context.Canceled) {
|
||||
r.log.Debug("remote emby request canceled", fields...)
|
||||
} else {
|
||||
r.log.Warn("remote emby request failed", fields...)
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
@@ -629,7 +637,7 @@ func (r *EmbyRemoteService) doGetOnLine(ctx context.Context, acct *model.StrmAcc
|
||||
req.Header.Set("X-Emby-Token", cfg.Token)
|
||||
resp, err := r.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("请求远程 Emby 失败: %w", err)
|
||||
return redactSensitiveError(fmt.Errorf("请求远程 Emby 失败: %w", err))
|
||||
}
|
||||
// 读 8MB+1 以区分"刚好 8MB"与"被截断":截断的 JSON 会让
|
||||
// Unmarshal 报 unexpected end,难以定位;这里显式报错。
|
||||
@@ -656,7 +664,7 @@ func (r *EmbyRemoteService) doGetOnLine(ctx context.Context, acct *model.StrmAcc
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("远程 Emby 请求失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
|
||||
return redactSensitiveError(fmt.Errorf("远程 Emby 请求失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data))))
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
@@ -1121,7 +1129,7 @@ func (r *EmbyRemoteService) proxyVideoStreamOnLine(ctx context.Context, w http.R
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
return fmt.Errorf("远程 Emby 视频流失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
|
||||
return redactSensitiveError(fmt.Errorf("远程 Emby 视频流失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data))))
|
||||
}
|
||||
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Cache-Control"} {
|
||||
if value := resp.Header.Get(header); value != "" {
|
||||
@@ -1281,12 +1289,12 @@ func (r *EmbyRemoteService) doMutateOnLine(ctx context.Context, cfg *EmbyRemoteC
|
||||
req.Header.Set("X-Emby-Token", cfg.Token)
|
||||
resp, err := r.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("请求远程 Emby 失败: %w", err)
|
||||
return redactSensitiveError(fmt.Errorf("请求远程 Emby 失败: %w", err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
return fmt.Errorf("远程 Emby 状态同步失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
|
||||
return redactSensitiveError(fmt.Errorf("远程 Emby 状态同步失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data))))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -180,18 +180,20 @@ func downloadFFmpegArchive(ctx context.Context, log *zap.Logger, urls []string,
|
||||
var lastErr error
|
||||
for i, u := range urls {
|
||||
if i > 0 && log != nil {
|
||||
log.Warn("ffmpeg 主下载源不可用,切换备用源", zap.String("url", u))
|
||||
log.Warn("ffmpeg 主下载源不可用,切换备用源", zap.String("url", redactSensitiveURL(u)))
|
||||
}
|
||||
if err := downloadFFmpegFile(ctx, log, u, dest); err != nil {
|
||||
lastErr = err
|
||||
if log != nil {
|
||||
log.Warn("ffmpeg 下载失败", zap.String("url", u), zap.Error(err))
|
||||
log.Warn("ffmpeg 下载失败",
|
||||
zap.String("url", redactSensitiveURL(u)),
|
||||
zap.Error(redactSensitiveError(err)))
|
||||
}
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("所有下载源均失败:%v", lastErr)
|
||||
return redactSensitiveError(fmt.Errorf("所有下载源均失败:%v", lastErr))
|
||||
}
|
||||
|
||||
// downloadFFmpegFile 下载单个归档文件(最多 10 分钟,限制大小上限)。
|
||||
@@ -221,10 +223,12 @@ func downloadFFmpegFile(ctx context.Context, log *zap.Logger, url, dest string)
|
||||
return err
|
||||
}
|
||||
if n > 500<<20 {
|
||||
return fmt.Errorf("归档文件过大(>500MB): %s", url)
|
||||
return fmt.Errorf("归档文件过大(>500MB): %s", redactSensitiveURL(url))
|
||||
}
|
||||
if log != nil {
|
||||
log.Info("ffmpeg 归档下载完成", zap.String("url", url), zap.Int64("bytes", n))
|
||||
log.Info("ffmpeg 归档下载完成",
|
||||
zap.String("url", redactSensitiveURL(url)),
|
||||
zap.Int64("bytes", n))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -196,14 +196,14 @@ func (p *ImageProxy) fetchAndCacheRemoteImage(ctx context.Context, raw, host, ca
|
||||
p.writeImageCache(cachePath, failPath, "img-*.tmp", data)
|
||||
return data, ctype, contentLength, nil
|
||||
}
|
||||
p.log.Warn("imageproxy: curl fallback failed", zap.String("host", host), zap.Error(err))
|
||||
logImageFetchError(p.log, "imageproxy: curl fallback failed", host, "curl", err)
|
||||
lastErr = err
|
||||
}
|
||||
p.markImageFetchFailed(failPath)
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("upstream image fetch failed")
|
||||
}
|
||||
return nil, "", "", lastErr
|
||||
return nil, "", "", redactSensitiveError(lastErr)
|
||||
}
|
||||
|
||||
type sharedRemoteImageResult struct {
|
||||
|
||||
@@ -48,14 +48,14 @@ func (p *ImageProxy) canUseExternalImageFallback() bool {
|
||||
func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string, candidate remoteImageFetchClient) ([]byte, string, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
|
||||
if err != nil {
|
||||
p.log.Warn("imageproxy: build request failed", zap.String("url", raw), zap.Error(err))
|
||||
p.log.Warn("imageproxy: build request failed", zap.String("url", redactSensitiveURL(raw)), zap.Error(redactSensitiveError(err)))
|
||||
return nil, "", "", errImageProxyRequestSetup
|
||||
}
|
||||
applyRemoteImageHeaders(req, host, raw)
|
||||
|
||||
resp, err := candidate.client.Do(req)
|
||||
if err != nil {
|
||||
p.log.Warn("imageproxy: upstream fetch failed", zap.String("host", host), zap.String("client", candidate.name), zap.Error(err))
|
||||
logImageFetchError(p.log, "imageproxy: upstream fetch failed", host, candidate.name, err)
|
||||
return nil, "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
@@ -65,7 +65,7 @@ func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string,
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||
if err != nil || len(data) == 0 {
|
||||
p.log.Warn("imageproxy: read upstream body failed", zap.String("host", host), zap.String("client", candidate.name), zap.Error(err))
|
||||
p.log.Warn("imageproxy: read upstream body failed", zap.String("host", host), zap.String("client", candidate.name), zap.Error(redactSensitiveError(err)))
|
||||
if err == nil {
|
||||
err = errors.New("upstream image body is empty")
|
||||
}
|
||||
@@ -79,6 +79,22 @@ func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string,
|
||||
return data, ctype, resp.Header.Get("Content-Length"), nil
|
||||
}
|
||||
|
||||
func logImageFetchError(log *zap.Logger, message, host, client string, err error) {
|
||||
if log == nil || err == nil {
|
||||
return
|
||||
}
|
||||
fields := []zap.Field{
|
||||
zap.String("host", host),
|
||||
zap.String("client", client),
|
||||
zap.Error(redactSensitiveError(err)),
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
log.Debug(message, fields...)
|
||||
return
|
||||
}
|
||||
log.Warn(message, fields...)
|
||||
}
|
||||
|
||||
func applyRemoteImageHeaders(req *http.Request, host, raw string) {
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36")
|
||||
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
|
||||
@@ -162,9 +178,9 @@ func fetchRemoteImageWithCurl(ctx context.Context, raw, host string) ([]byte, st
|
||||
"--header", "Cache-Control: no-cache",
|
||||
"--header", "Pragma: no-cache",
|
||||
}
|
||||
if referer := remoteImageReferer(host, raw); referer != "" {
|
||||
args = append(args, "--referer", referer)
|
||||
}
|
||||
if referer := remoteImageReferer(host, raw); referer != "" {
|
||||
args = append(args, "--referer", referer)
|
||||
}
|
||||
if cookie := remoteImageCookie(host); cookie != "" {
|
||||
args = append(args, "--cookie", cookie)
|
||||
}
|
||||
@@ -188,9 +204,9 @@ func fetchRemoteImageWithCurl(ctx context.Context, raw, host string) ([]byte, st
|
||||
if waitErr != nil {
|
||||
message := strings.TrimSpace(stderr.String())
|
||||
if message != "" {
|
||||
return nil, "", "", errors.New(message)
|
||||
return nil, "", "", redactSensitiveError(errors.New(message))
|
||||
}
|
||||
return nil, "", "", waitErr
|
||||
return nil, "", "", redactSensitiveError(waitErr)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, "", "", errors.New("curl image body is empty")
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const sensitiveLogKeyPattern = `api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|passwd|pwd|authorization|client[_-]?secret|secret|signature|sig|x[_-]?emby[_-]?token|x[_-]?api[_-]?key|x[_-]?amz[_-]?signature|x[_-]?amz[_-]?credential|x[_-]?amz[_-]?security[_-]?token|awsaccesskeyid|session[_-]?id`
|
||||
|
||||
var (
|
||||
sensitiveLogQueryRE = regexp.MustCompile(`(?i)([?&;])(` + sensitiveLogKeyPattern + `)=([^&#\s"']+)`)
|
||||
sensitiveLogJSONRE = regexp.MustCompile(`(?i)("(?:` + sensitiveLogKeyPattern + `)"\s*:\s*")((?:[^"\\]|\\.)*)(")`)
|
||||
sensitiveLogAssignmentRE = regexp.MustCompile(`(?im)((?:^|[\s,{])(?:` + sensitiveLogKeyPattern + `)\s*[:=]\s*(?:(?:bearer|basic)\s+)?)([^\s,;"'}\]]+)`)
|
||||
)
|
||||
|
||||
// redactSensitiveURL removes credentials from URLs before they reach logs or
|
||||
// error messages. Query values are replaced with REDACTED and URL userinfo
|
||||
// passwords are removed.
|
||||
func redactSensitiveURL(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
u, err := url.Parse(trimmed)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return redactSensitiveText(raw)
|
||||
}
|
||||
if u.User != nil {
|
||||
if _, hasPassword := u.User.Password(); hasPassword {
|
||||
u.User = url.UserPassword(u.User.Username(), "REDACTED")
|
||||
}
|
||||
}
|
||||
query := u.Query()
|
||||
changed := false
|
||||
for key := range query {
|
||||
if isSensitiveLogKey(key) {
|
||||
query.Set(key, "REDACTED")
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
u.RawQuery = query.Encode()
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func redactSensitiveText(value string) string {
|
||||
value = sensitiveLogQueryRE.ReplaceAllString(value, `${1}${2}=REDACTED`)
|
||||
value = sensitiveLogJSONRE.ReplaceAllString(value, `${1}REDACTED${3}`)
|
||||
return sensitiveLogAssignmentRE.ReplaceAllString(value, `${1}REDACTED`)
|
||||
}
|
||||
|
||||
type logRedactedError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e logRedactedError) Error() string {
|
||||
return redactSensitiveText(e.err.Error())
|
||||
}
|
||||
|
||||
func (e logRedactedError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
// redactSensitiveError wraps an error with a redacted Error string while
|
||||
// preserving errors.Is and errors.As behavior.
|
||||
func redactSensitiveError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var alreadyRedacted logRedactedError
|
||||
if errors.As(err, &alreadyRedacted) {
|
||||
return err
|
||||
}
|
||||
return logRedactedError{err: err}
|
||||
}
|
||||
|
||||
func isSensitiveLogKey(key string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(key))
|
||||
normalized = strings.ReplaceAll(normalized, "-", "_")
|
||||
switch normalized {
|
||||
case "api_key", "apikey", "access_token", "refresh_token", "id_token", "token",
|
||||
"password", "passwd", "pwd", "authorization", "client_secret", "secret",
|
||||
"signature", "sig", "x_emby_token", "x_api_key", "x_amz_signature",
|
||||
"x_amz_credential", "x_amz_security_token", "awsaccesskeyid", "session_id":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRedactSensitiveURL(t *testing.T) {
|
||||
raw := "https://user:secret@media.example/emby/Items/1/Images/primary?api_key=abc123&X-Emby-Token=xyz&X-Amz-Signature=sig123&quality=90"
|
||||
got := redactSensitiveURL(raw)
|
||||
for _, secret := range []string{"secret", "abc123", "xyz", "sig123"} {
|
||||
if strings.Contains(got, secret) {
|
||||
t.Fatalf("redacted URL still contains %q: %s", secret, got)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got, "quality=90") {
|
||||
t.Fatalf("non-sensitive query value was removed: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactSensitiveTextCoversJSONAndHeaders(t *testing.T) {
|
||||
raw := `{"AccessToken":"json-token","quality":"90"} Authorization: Bearer bearer-token; X-Emby-Token=header-token`
|
||||
got := redactSensitiveText(raw)
|
||||
for _, secret := range []string{"json-token", "bearer-token", "header-token"} {
|
||||
if strings.Contains(got, secret) {
|
||||
t.Fatalf("redacted text still contains %q: %s", secret, got)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got, `"quality":"90"`) {
|
||||
t.Fatalf("non-sensitive JSON value was removed: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactSensitiveErrorPreservesWrapping(t *testing.T) {
|
||||
base := errors.New(`Get "https://media.example/item?token=secret&quality=90": context canceled`)
|
||||
err := redactSensitiveError(context.Canceled)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatal("redacted error lost context.Canceled")
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret") {
|
||||
t.Fatalf("redacted error leaked token: %s", err.Error())
|
||||
}
|
||||
redactedBase := redactSensitiveError(base)
|
||||
if strings.Contains(redactedBase.Error(), "secret") {
|
||||
t.Fatalf("redacted base error leaked token: %s", redactedBase.Error())
|
||||
}
|
||||
if !errors.Is(redactedBase, base) {
|
||||
t.Fatal("redacted error lost original error identity")
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,10 @@ const (
|
||||
type RuntimeCacheService struct {
|
||||
log *zap.Logger
|
||||
client *redis.Client
|
||||
prefix string
|
||||
// redisGet is separated from client so cache ordering can be tested without
|
||||
// requiring a live Redis instance.
|
||||
redisGet func(ctx context.Context, key string) ([]byte, error)
|
||||
prefix string
|
||||
|
||||
mu sync.RWMutex
|
||||
memory map[string]runtimeCacheItem
|
||||
@@ -36,10 +39,11 @@ type RuntimeCacheService struct {
|
||||
}
|
||||
|
||||
type runtimeCacheItem struct {
|
||||
raw []byte
|
||||
expiresAt time.Time
|
||||
lastUsed time.Time
|
||||
size int64
|
||||
raw []byte
|
||||
expiresAt time.Time
|
||||
staleUntil time.Time
|
||||
lastUsed time.Time
|
||||
size int64
|
||||
}
|
||||
|
||||
// runtimeObjectItem 直存 Go 对象,跳过 JSON 编解码。热点路径(整库行、
|
||||
@@ -95,6 +99,9 @@ func NewRuntimeCacheService(cfg *config.Config, log *zap.Logger) *RuntimeCacheSe
|
||||
return c
|
||||
}
|
||||
c.client = client
|
||||
c.redisGet = func(ctx context.Context, key string) ([]byte, error) {
|
||||
return client.Get(ctx, key).Bytes()
|
||||
}
|
||||
if log != nil {
|
||||
log.Info("redis runtime cache enabled with in-process L1", zap.String("addr", opts.Addr), zap.String("prefix", c.prefix))
|
||||
}
|
||||
@@ -120,8 +127,8 @@ func (c *RuntimeCacheService) GetJSON(ctx context.Context, key string, out any)
|
||||
if raw, ok := c.getMemory(fullKey); ok {
|
||||
return json.Unmarshal(raw, out) == nil
|
||||
}
|
||||
if c.client != nil {
|
||||
raw, err := c.client.Get(ctx, fullKey).Bytes()
|
||||
if c.redisGet != nil {
|
||||
raw, err := c.redisGet(ctx, fullKey)
|
||||
if err == nil {
|
||||
if json.Unmarshal(raw, out) != nil {
|
||||
return false
|
||||
@@ -133,6 +140,36 @@ func (c *RuntimeCacheService) GetJSON(ctx context.Context, key string, out any)
|
||||
return false
|
||||
}
|
||||
|
||||
// GetJSONStale returns a fresh value when available and otherwise a retained
|
||||
// stale value stored with SetJSONWithStale. The stale flag lets
|
||||
// callers serve immediately while refreshing in the background.
|
||||
func (c *RuntimeCacheService) GetJSONStale(ctx context.Context, key string, out any) (found bool, stale bool) {
|
||||
if !c.Enabled() || strings.TrimSpace(key) == "" || out == nil {
|
||||
return false, false
|
||||
}
|
||||
fullKey := c.key(key)
|
||||
raw, ok, isStale := c.getMemoryWithStale(fullKey)
|
||||
if ok && !isStale {
|
||||
return json.Unmarshal(raw, out) == nil, false
|
||||
}
|
||||
// A stale L1 entry must not hide a newer value written by another
|
||||
// instance. Prefer Redis whenever the local copy is stale, then fall back
|
||||
// to it only when Redis is unavailable or its fresh value has expired.
|
||||
if c.redisGet != nil {
|
||||
redisRaw, err := c.redisGet(ctx, fullKey)
|
||||
if err == nil {
|
||||
if json.Unmarshal(redisRaw, out) == nil {
|
||||
c.setMemoryOwned(fullKey, redisRaw, 2*time.Second)
|
||||
return true, false
|
||||
}
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
return json.Unmarshal(raw, out) == nil, isStale
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
func (c *RuntimeCacheService) SetJSON(ctx context.Context, key string, value any, ttl time.Duration) {
|
||||
if !c.Enabled() || strings.TrimSpace(key) == "" || value == nil || ttl <= 0 {
|
||||
return
|
||||
@@ -148,6 +185,27 @@ func (c *RuntimeCacheService) SetJSON(ctx context.Context, key string, value any
|
||||
}
|
||||
}
|
||||
|
||||
// SetJSONWithStale stores a fresh value for freshTTL and keeps an in-process
|
||||
// stale copy for staleTTL. Redis keeps only the fresh window so multi-instance
|
||||
// deployments retain the existing consistency semantics.
|
||||
func (c *RuntimeCacheService) SetJSONWithStale(ctx context.Context, key string, value any, freshTTL, staleTTL time.Duration) {
|
||||
if !c.Enabled() || strings.TrimSpace(key) == "" || value == nil || freshTTL <= 0 {
|
||||
return
|
||||
}
|
||||
if staleTTL < freshTTL {
|
||||
staleTTL = freshTTL
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fullKey := c.key(key)
|
||||
c.setMemoryBytesWithStale(fullKey, raw, freshTTL, staleTTL, true)
|
||||
if c.client != nil {
|
||||
_ = c.client.Set(ctx, fullKey, raw, freshTTL).Err()
|
||||
}
|
||||
}
|
||||
|
||||
// GetObject 返回缓存中的对象。返回值不可变:调用方需要修改时必须先自行拷贝。
|
||||
func (c *RuntimeCacheService) GetObject(key string) (any, bool) {
|
||||
if !c.Enabled() || strings.TrimSpace(key) == "" {
|
||||
@@ -255,20 +313,29 @@ func (c *RuntimeCacheService) key(key string) string {
|
||||
}
|
||||
|
||||
func (c *RuntimeCacheService) getMemory(key string) ([]byte, bool) {
|
||||
raw, ok, stale := c.getMemoryWithStale(key)
|
||||
return raw, ok && !stale
|
||||
}
|
||||
|
||||
func (c *RuntimeCacheService) getMemoryWithStale(key string) ([]byte, bool, bool) {
|
||||
now := time.Now()
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
item, ok := c.memory[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
return nil, false, false
|
||||
}
|
||||
if !now.Before(item.expiresAt) {
|
||||
staleUntil := item.staleUntil
|
||||
if staleUntil.IsZero() {
|
||||
staleUntil = item.expiresAt
|
||||
}
|
||||
if !now.Before(staleUntil) {
|
||||
c.removeMemoryLocked(key)
|
||||
return nil, false
|
||||
return nil, false, false
|
||||
}
|
||||
item.lastUsed = now
|
||||
c.memory[key] = item
|
||||
return item.raw, true
|
||||
return item.raw, true, !now.Before(item.expiresAt)
|
||||
}
|
||||
|
||||
func (c *RuntimeCacheService) setMemory(key string, raw []byte, ttl time.Duration) {
|
||||
@@ -280,9 +347,16 @@ func (c *RuntimeCacheService) setMemoryOwned(key string, raw []byte, ttl time.Du
|
||||
}
|
||||
|
||||
func (c *RuntimeCacheService) setMemoryBytes(key string, raw []byte, ttl time.Duration, owned bool) {
|
||||
if ttl <= 0 || len(raw) == 0 {
|
||||
c.setMemoryBytesWithStale(key, raw, ttl, ttl, owned)
|
||||
}
|
||||
|
||||
func (c *RuntimeCacheService) setMemoryBytesWithStale(key string, raw []byte, freshTTL, staleTTL time.Duration, owned bool) {
|
||||
if freshTTL <= 0 || len(raw) == 0 {
|
||||
return
|
||||
}
|
||||
if staleTTL < freshTTL {
|
||||
staleTTL = freshTTL
|
||||
}
|
||||
size := int64(len(key)+len(raw)) + runtimeCacheEntryOverheadBytes
|
||||
now := time.Now()
|
||||
c.mu.Lock()
|
||||
@@ -298,10 +372,11 @@ func (c *RuntimeCacheService) setMemoryBytes(key string, raw []byte, ttl time.Du
|
||||
raw = append([]byte(nil), raw...)
|
||||
}
|
||||
c.memory[key] = runtimeCacheItem{
|
||||
raw: raw,
|
||||
expiresAt: now.Add(ttl),
|
||||
lastUsed: now,
|
||||
size: size,
|
||||
raw: raw,
|
||||
expiresAt: now.Add(freshTTL),
|
||||
staleUntil: now.Add(staleTTL),
|
||||
lastUsed: now,
|
||||
size: size,
|
||||
}
|
||||
c.bytesUsed += size
|
||||
}
|
||||
@@ -334,7 +409,11 @@ func (c *RuntimeCacheService) entryCountLocked() int {
|
||||
|
||||
func (c *RuntimeCacheService) evictExpiredLocked(now time.Time) {
|
||||
for key, item := range c.memory {
|
||||
if !now.Before(item.expiresAt) {
|
||||
staleUntil := item.staleUntil
|
||||
if staleUntil.IsZero() {
|
||||
staleUntil = item.expiresAt
|
||||
}
|
||||
if !now.Before(staleUntil) {
|
||||
c.removeMemoryLocked(key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,3 +116,74 @@ func TestRuntimeCacheSetMaxSizeEvictsImmediately(t *testing.T) {
|
||||
t.Fatal("newest entry should remain after lowering cache limit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeCacheReturnsStaleValueAfterFreshTTL(t *testing.T) {
|
||||
cache := newRuntimeCacheForTest(t, 1)
|
||||
key := "latest:stale"
|
||||
cache.SetJSONWithStale(context.Background(), key, "cached-value", time.Minute, time.Hour)
|
||||
|
||||
fullKey := cache.key(key)
|
||||
cache.mu.Lock()
|
||||
item := cache.memory[fullKey]
|
||||
item.expiresAt = time.Now().Add(-time.Second)
|
||||
cache.memory[fullKey] = item
|
||||
cache.mu.Unlock()
|
||||
|
||||
var fresh string
|
||||
if cache.GetJSON(context.Background(), key, &fresh) {
|
||||
t.Fatal("expired fresh entry must not be returned by GetJSON")
|
||||
}
|
||||
var stale string
|
||||
found, isStale := cache.GetJSONStale(context.Background(), key, &stale)
|
||||
if !found || !isStale {
|
||||
t.Fatalf("GetJSONStale found=%t stale=%t, want true/true", found, isStale)
|
||||
}
|
||||
if stale != "cached-value" {
|
||||
t.Fatalf("stale value=%q, want cached-value", stale)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeCacheDoesNotReturnOrdinaryEntryAsStale(t *testing.T) {
|
||||
cache := newRuntimeCacheForTest(t, 1)
|
||||
key := "latest:ordinary"
|
||||
cache.SetJSON(context.Background(), key, "cached-value", time.Minute)
|
||||
|
||||
fullKey := cache.key(key)
|
||||
cache.mu.Lock()
|
||||
item := cache.memory[fullKey]
|
||||
expiredAt := time.Now().Add(-time.Second)
|
||||
item.expiresAt = expiredAt
|
||||
item.staleUntil = expiredAt
|
||||
cache.memory[fullKey] = item
|
||||
cache.mu.Unlock()
|
||||
|
||||
var out string
|
||||
if found, isStale := cache.GetJSONStale(context.Background(), key, &out); found || isStale {
|
||||
t.Fatalf("ordinary expired entry found=%t stale=%t, want false/false", found, isStale)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeCacheStalePrefersFreshRedisValue(t *testing.T) {
|
||||
cache := newRuntimeCacheForTest(t, 1)
|
||||
key := "latest:redis-fresh"
|
||||
cache.SetJSONWithStale(context.Background(), key, "local-stale", time.Minute, time.Hour)
|
||||
|
||||
fullKey := cache.key(key)
|
||||
cache.mu.Lock()
|
||||
item := cache.memory[fullKey]
|
||||
item.expiresAt = time.Now().Add(-time.Second)
|
||||
cache.memory[fullKey] = item
|
||||
cache.mu.Unlock()
|
||||
|
||||
cache.redisGet = func(context.Context, string) ([]byte, error) {
|
||||
return []byte(`"redis-fresh"`), nil
|
||||
}
|
||||
var out string
|
||||
found, stale := cache.GetJSONStale(context.Background(), key, &out)
|
||||
if !found || stale {
|
||||
t.Fatalf("GetJSONStale found=%t stale=%t, want true/false", found, stale)
|
||||
}
|
||||
if out != "redis-fresh" {
|
||||
t.Fatalf("value=%q, want redis-fresh", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +40,9 @@ func (s *ScraperService) prepareScrapedArtworkURL(ctx context.Context, mediaID,
|
||||
s.log.Warn("MetaTube artwork processing failed; using local face-aware crop",
|
||||
zap.String("media_id", mediaID),
|
||||
zap.String("field", field),
|
||||
zap.String("candidate", candidate),
|
||||
zap.String("source", originalSource),
|
||||
zap.Error(err))
|
||||
zap.String("candidate", redactSensitiveURL(candidate)),
|
||||
zap.String("source", redactSensitiveURL(originalSource)),
|
||||
zap.Error(redactSensitiveError(err)))
|
||||
if current != "" && isHTTPish(current) {
|
||||
return originalSource, current
|
||||
}
|
||||
@@ -53,16 +53,16 @@ func (s *ScraperService) prepareScrapedArtworkURL(ctx context.Context, mediaID,
|
||||
s.log.Warn("scrape artwork prefetch failed; keeping existing artwork",
|
||||
zap.String("media_id", mediaID),
|
||||
zap.String("field", field),
|
||||
zap.String("candidate", candidate),
|
||||
zap.String("existing", current),
|
||||
zap.Error(err))
|
||||
zap.String("candidate", redactSensitiveURL(candidate)),
|
||||
zap.String("existing", redactSensitiveURL(current)),
|
||||
zap.Error(redactSensitiveError(err)))
|
||||
return current, ""
|
||||
}
|
||||
s.log.Warn("scrape artwork prefetch failed; keeping new artwork URL for retry",
|
||||
zap.String("media_id", mediaID),
|
||||
zap.String("field", field),
|
||||
zap.String("candidate", candidate),
|
||||
zap.Error(err))
|
||||
zap.String("candidate", redactSensitiveURL(candidate)),
|
||||
zap.Error(redactSensitiveError(err)))
|
||||
return candidate, ""
|
||||
}
|
||||
if current != "" && isHTTPish(current) {
|
||||
@@ -98,7 +98,9 @@ func (s *ScraperService) removeCachedScrapedArtwork(urls ...string) {
|
||||
}
|
||||
seen[raw] = struct{}{}
|
||||
if err := s.images.RemoveCached(raw); err != nil {
|
||||
s.log.Debug("remove old scraped artwork cache failed", zap.String("url", raw), zap.Error(err))
|
||||
s.log.Debug("remove old scraped artwork cache failed",
|
||||
zap.String("url", redactSensitiveURL(raw)),
|
||||
zap.Error(redactSensitiveError(err)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,8 +117,8 @@ func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, d
|
||||
if err != nil || len(data) == 0 {
|
||||
s.log.Warn("scrape artwork download failed",
|
||||
zap.String("name", name),
|
||||
zap.String("url", raw),
|
||||
zap.Error(err))
|
||||
zap.String("url", redactSensitiveURL(raw)),
|
||||
zap.Error(redactSensitiveError(err)))
|
||||
return ""
|
||||
}
|
||||
if !isImageContentType(ctype) || isTransparentPlaceholderData(data) {
|
||||
|
||||
@@ -88,7 +88,9 @@ func (b *serviceContainerBuilder) configureMediaSearchBackend() {
|
||||
}
|
||||
b.repos.Media.SetSearchBackend(searchBackend)
|
||||
if b.log != nil {
|
||||
b.log.Info("opensearch media search enabled", zap.String("index", b.cfg.Search.Index), zap.String("url", b.cfg.Search.OpenSearchURL))
|
||||
b.log.Info("opensearch media search enabled",
|
||||
zap.String("index", b.cfg.Search.Index),
|
||||
zap.String("url", redactSensitiveURL(b.cfg.Search.OpenSearchURL)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||
)
|
||||
|
||||
@@ -156,6 +157,76 @@ func TestPersist115TokensKeepsSharedClient(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTestStrmAccountKeepsTokensRefreshedDuringPing 回归测试:
|
||||
// Ping 期间 115 客户端可能刷新并持久化 token。账号测试只能写 last_test_*
|
||||
// 字段,不能再用请求开始时读取的旧 Config 整包覆盖,否则新 token 会被旧值
|
||||
// 覆盖,最终导致账号在下次重启后失效。
|
||||
func TestTestStrmAccountKeepsTokensRefreshedDuringPing(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
ctx := context.Background()
|
||||
acct, err := svc.CreateStrmAccount(ctx, "115", model.StrmProvider115, map[string]string{
|
||||
"app_id": "100195129",
|
||||
"access_token": "at-old",
|
||||
"refresh_token": "rt-old",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
|
||||
svc.providerMu.Lock()
|
||||
svc.provider115Cache[acct.ID] = &refreshOnPing115Provider{svc: svc, accountID: acct.ID}
|
||||
svc.providerMu.Unlock()
|
||||
|
||||
got := svc.TestStrmAccount(ctx, acct.ID)
|
||||
if got == nil {
|
||||
t.Fatal("TestStrmAccount returned nil")
|
||||
}
|
||||
if !got.LastTestOK || got.LastTestResult != "ok" {
|
||||
t.Fatalf("test result = (%v, %q), want ok", got.LastTestOK, got.LastTestResult)
|
||||
}
|
||||
gotCfg, err := svc.strmAccountConfig(got)
|
||||
if err != nil {
|
||||
t.Fatalf("decode returned config: %v", err)
|
||||
}
|
||||
if gotCfg["access_token"] != "at-new" || gotCfg["refresh_token"] != "rt-new" {
|
||||
t.Fatalf("returned account lost refreshed tokens: %#v", gotCfg)
|
||||
}
|
||||
|
||||
fresh, err := svc.repo.StrmAccount.FindByID(ctx, acct.ID)
|
||||
if err != nil || fresh == nil {
|
||||
t.Fatalf("reload account: %v", err)
|
||||
}
|
||||
freshCfg, err := svc.strmAccountConfig(fresh)
|
||||
if err != nil {
|
||||
t.Fatalf("decode persisted config: %v", err)
|
||||
}
|
||||
if freshCfg["access_token"] != "at-new" || freshCfg["refresh_token"] != "rt-new" {
|
||||
t.Fatalf("persisted account lost refreshed tokens: %#v", freshCfg)
|
||||
}
|
||||
}
|
||||
|
||||
// refreshOnPing115Provider 模拟真实 115 客户端在 Ping 内完成 token 轮转并
|
||||
// 通过持久化回调写回新 token 的行为。
|
||||
type refreshOnPing115Provider struct {
|
||||
svc *StrmService
|
||||
accountID string
|
||||
}
|
||||
|
||||
func (p *refreshOnPing115Provider) Type() string { return model.StrmProvider115 }
|
||||
|
||||
func (p *refreshOnPing115Provider) Ping(context.Context) error {
|
||||
p.svc.persist115Tokens(p.accountID, "at-new", "rt-new")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *refreshOnPing115Provider) List(context.Context, string) ([]cloud.FileEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *refreshOnPing115Provider) Resolve(context.Context, string) (*cloud.DirectLink, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestDeleteStrmAccountCascadesEmbyMounts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{}, &model.StrmSyncPath{})
|
||||
|
||||
@@ -449,19 +449,37 @@ func (s *StrmService) TestStrmAccount(ctx context.Context, id string) *model.Str
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
acct.LastTestAt = &now
|
||||
result := ""
|
||||
ok := false
|
||||
provider, err := s.providerFor(ctx, acct)
|
||||
if err != nil {
|
||||
acct.LastTestResult = err.Error()
|
||||
acct.LastTestOK = false
|
||||
result = err.Error()
|
||||
} else if err := provider.Ping(ctx); err != nil {
|
||||
acct.LastTestResult = err.Error()
|
||||
acct.LastTestOK = false
|
||||
result = err.Error()
|
||||
} else {
|
||||
acct.LastTestResult = "ok"
|
||||
acct.LastTestOK = true
|
||||
result = "ok"
|
||||
ok = true
|
||||
}
|
||||
_ = s.repo.StrmAccount.Update(ctx, acct)
|
||||
// Ping 期间 115 客户端可能刷新 access/refresh token,并通过
|
||||
// OnTokenRefreshed 持久化新配置。这里只写测试结果字段,不能把请求开始时
|
||||
// 读取的旧 acct.Config 整包写回,否则会把刚轮转的 token 覆盖失效。
|
||||
updateErr := s.repo.StrmAccount.UpdateTestResult(ctx, id, now, result, ok)
|
||||
if updateErr != nil && s.log != nil {
|
||||
s.log.Warn("update strm account test result failed", zap.String("account_id", id), zap.Error(updateErr))
|
||||
}
|
||||
// 重新读取,确保返回给前端的账号配置已经是 Ping 期间刷新后的版本。
|
||||
if fresh, err := s.repo.StrmAccount.FindByID(ctx, id); err == nil && fresh != nil {
|
||||
if updateErr != nil {
|
||||
// 写库失败时仍让本次响应展示刚完成测试的结果。
|
||||
fresh.LastTestAt = &now
|
||||
fresh.LastTestResult = result
|
||||
fresh.LastTestOK = ok
|
||||
}
|
||||
return fresh
|
||||
}
|
||||
acct.LastTestAt = &now
|
||||
acct.LastTestResult = result
|
||||
acct.LastTestOK = ok
|
||||
return acct
|
||||
}
|
||||
|
||||
|
||||
Vendored
+42
@@ -0,0 +1,42 @@
|
||||
param(
|
||||
[string]$Output = "dist\mebox-windows-amd64.exe",
|
||||
[string]$Version = "dev",
|
||||
[switch]$SkipFrontend
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
Push-Location $root
|
||||
try {
|
||||
if (-not $SkipFrontend) {
|
||||
Push-Location web
|
||||
try {
|
||||
npm ci
|
||||
npm run build
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path "web\dist\index.html")) {
|
||||
throw "web\dist\index.html is missing. Run without -SkipFrontend or build the frontend first."
|
||||
}
|
||||
|
||||
$outputDir = Split-Path -Parent $Output
|
||||
if ($outputDir) {
|
||||
New-Item -ItemType Directory -Force -Path $outputDir | Out-Null
|
||||
}
|
||||
$env:CGO_ENABLED = "0"
|
||||
$env:GOOS = "windows"
|
||||
$env:GOARCH = "amd64"
|
||||
go build -trimpath `
|
||||
-ldflags="-s -w -H=windowsgui -X main.version=$Version" `
|
||||
-o $Output `
|
||||
./cmd/server
|
||||
|
||||
Write-Host "Built $Output"
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
Generated
+700
-960
File diff suppressed because it is too large
Load Diff
+7
-4
@@ -3,6 +3,9 @@
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
@@ -10,7 +13,7 @@
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.2",
|
||||
"axios": "^1.20.0",
|
||||
"clsx": "^2.1.1",
|
||||
"danmu": "^0.20.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
@@ -22,7 +25,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router-dom": "^6.23.1",
|
||||
"react-router-dom": "^7.18.3",
|
||||
"react-virtuoso": "^4.18.12",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
@@ -31,7 +34,7 @@
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
@@ -41,6 +44,6 @@
|
||||
"tailwindcss": "^3.4.4",
|
||||
"typescript": "^5.4.5",
|
||||
"typescript-eslint": "^8.60.1",
|
||||
"vite": "^5.3.1"
|
||||
"vite": "^8.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@': path.resolve(import.meta.dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
|
||||
Reference in New Issue
Block a user