mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b28c0d9247 | |||
| 91c6d9dfd9 | |||
| 95d8126d65 |
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -225,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>
|
||||
@@ -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
|
||||
@@ -48,6 +49,7 @@ 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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 |
@@ -0,0 +1,148 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func mediaPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc == nil || svc.Cloud115 == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"})
|
||||
return
|
||||
}
|
||||
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if !enforceScopedPlaybackToken(c, m.ID) {
|
||||
return
|
||||
}
|
||||
quality, _ := strconv.Atoi(c.Query("quality"))
|
||||
info, err := svc.Cloud115.PlaybackInfo(c.Request.Context(), m.ID, quality)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrMediaNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, info)
|
||||
}
|
||||
}
|
||||
|
||||
func mediaTranscodeHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc == nil || svc.Cloud115 == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"})
|
||||
return
|
||||
}
|
||||
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if !enforceScopedPlaybackToken(c, m.ID) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Definition int `json:"definition"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
info, err := svc.Cloud115.StartTranscode(c.Request.Context(), m.ID, req.Definition)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, info)
|
||||
}
|
||||
}
|
||||
|
||||
func cloud115HLSMasterHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc == nil || svc.Cloud115 == nil || svc.Cloud115.HLSProxy() == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"})
|
||||
return
|
||||
}
|
||||
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if !enforceScopedPlaybackToken(c, m.ID) {
|
||||
return
|
||||
}
|
||||
definition, _ := strconv.Atoi(c.Query("definition"))
|
||||
err = svc.Cloud115.HLSProxy().ServeMaster(c.Request.Context(), c.Writer, c.Request, m.ID, definition)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if c.Writer.Written() {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrCloud115TranscodePending) {
|
||||
retryAfter := 5
|
||||
message := "115 正在转码,请稍候"
|
||||
var info *service.PlaybackInfo
|
||||
if latest, infoErr := svc.Cloud115.PlaybackInfo(c.Request.Context(), m.ID, definition); infoErr == nil && latest != nil {
|
||||
info = latest
|
||||
if latest.Transcode.Message != "" {
|
||||
message = latest.Transcode.Message
|
||||
}
|
||||
if latest.Transcode.RetryAfterSec > 0 {
|
||||
retryAfter = latest.Transcode.RetryAfterSec
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"code": "transcode_pending",
|
||||
"message": message,
|
||||
"retry_after_sec": retryAfter,
|
||||
"playback": info,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
func cloud115HLSSessionHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc == nil || svc.Cloud115 == nil || svc.Cloud115.HLSProxy() == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"})
|
||||
return
|
||||
}
|
||||
mediaID := c.Query("media_id")
|
||||
if mediaID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing media_id"})
|
||||
return
|
||||
}
|
||||
if !enforceScopedPlaybackToken(c, mediaID) {
|
||||
return
|
||||
}
|
||||
err := svc.Cloud115.HLSProxy().ServeChild(
|
||||
c.Request.Context(),
|
||||
c.Writer,
|
||||
c.Request,
|
||||
c.Param("session"),
|
||||
c.Param("key"),
|
||||
)
|
||||
if err == nil || c.Writer.Written() {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, service.ErrCloud115HLSSessionNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "hls session not found"})
|
||||
case errors.Is(err, service.ErrCloud115HLSUpstreamExpired):
|
||||
c.JSON(http.StatusGone, gin.H{"error": "hls upstream expired"})
|
||||
default:
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,8 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container
|
||||
func registerAuthedMediaRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/media/:id", getMediaHandler(svc))
|
||||
authed.GET("/media/:id/episodes", listMediaEpisodesHandler(svc))
|
||||
authed.GET("/media/:id/playback", mediaPlaybackInfoHandler(svc))
|
||||
authed.POST("/media/:id/transcode", mediaTranscodeHandler(svc))
|
||||
authed.GET("/media", searchMediaHandler(svc))
|
||||
authed.PATCH("/media/:id/metadata", middleware.AdminRequired(), updateMediaMetadataHandler(svc))
|
||||
authed.POST("/media/:id/scrape", middleware.AdminRequired(), scrapeOneHandler(svc))
|
||||
@@ -65,6 +67,10 @@ func registerAuthedPlaybackAndProxyRoutes(authed *gin.RouterGroup, svc *service.
|
||||
authed.GET("/hls/:id/index.m3u8", hlsPlaylistHandler(svc))
|
||||
authed.GET("/hls/:id/:seg", hlsSegmentHandler(svc))
|
||||
authed.DELETE("/hls/:id", stopTranscodeHandler(svc))
|
||||
authed.GET("/cloud115/media/:id/master.m3u8", cloud115HLSMasterHandler(svc))
|
||||
authed.HEAD("/cloud115/media/:id/master.m3u8", cloud115HLSMasterHandler(svc))
|
||||
authed.GET("/cloud115/hls/:session/:key", cloud115HLSSessionHandler(svc))
|
||||
authed.HEAD("/cloud115/hls/:session/:key", cloud115HLSSessionHandler(svc))
|
||||
|
||||
authed.GET("/img", imageProxyHandler(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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,8 @@ func externalPlaybackTokenAllowedPath(c *gin.Context, mediaID string) bool {
|
||||
return len(segments) >= 3 &&
|
||||
strings.EqualFold(segments[2], "play") &&
|
||||
strings.TrimSpace(c.Query("media_id")) == mediaID
|
||||
case "cloud115":
|
||||
return strings.TrimSpace(c.Query("media_id")) == mediaID
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// 115 开放平台视频播放/转码 API。
|
||||
//
|
||||
// 播放接口返回的是 115 云端转码后的 HLS(m3u8)地址,不是原文件
|
||||
// downurl;未完成转码时接口会返回 state=false,可再调用 video_push
|
||||
// 请求加速转码。
|
||||
package cloud115
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FlexString 兼容 115 接口同字段在不同设备端返回字符串或数字的情况。
|
||||
type FlexString string
|
||||
|
||||
func (s *FlexString) UnmarshalJSON(data []byte) error {
|
||||
raw := bytes.TrimSpace(data)
|
||||
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
||||
*s = ""
|
||||
return nil
|
||||
}
|
||||
var str string
|
||||
if err := json.Unmarshal(raw, &str); err == nil {
|
||||
*s = FlexString(str)
|
||||
return nil
|
||||
}
|
||||
var num json.Number
|
||||
if err := json.Unmarshal(raw, &num); err == nil {
|
||||
*s = FlexString(num.String())
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("115: 无法解析字符串/数字字段 %s", string(raw))
|
||||
}
|
||||
|
||||
func (s FlexString) String() string {
|
||||
return string(s)
|
||||
}
|
||||
|
||||
func (s FlexString) Int64() int64 {
|
||||
v, _ := strconv.ParseInt(strings.TrimSpace(string(s)), 10, 64)
|
||||
return v
|
||||
}
|
||||
|
||||
// MultiTrack 是 115 返回的一条音轨信息。
|
||||
type MultiTrack struct {
|
||||
Title string `json:"title"`
|
||||
IsSelected string `json:"is_selected"`
|
||||
SyncTime string `json:"sync_time"`
|
||||
}
|
||||
|
||||
// MultiTrackList 兼容 115 接口把 multitrack_list 返回成对象(键为音轨序号)
|
||||
// 或数组的两种形态。
|
||||
type MultiTrackList []MultiTrack
|
||||
|
||||
func (l *MultiTrackList) UnmarshalJSON(data []byte) error {
|
||||
raw := bytes.TrimSpace(data)
|
||||
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) || bytes.Equal(raw, []byte("{}")) {
|
||||
*l = nil
|
||||
return nil
|
||||
}
|
||||
if raw[0] == '[' {
|
||||
var arr []MultiTrack
|
||||
if err := json.Unmarshal(raw, &arr); err != nil {
|
||||
return err
|
||||
}
|
||||
*l = arr
|
||||
return nil
|
||||
}
|
||||
if raw[0] == '{' {
|
||||
var m map[string]MultiTrack
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return err
|
||||
}
|
||||
keys := make([]int, 0, len(m))
|
||||
for key := range m {
|
||||
idx, err := strconv.Atoi(strings.TrimSpace(key))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, idx)
|
||||
}
|
||||
sort.Ints(keys)
|
||||
out := make([]MultiTrack, 0, len(keys))
|
||||
for _, idx := range keys {
|
||||
out = append(out, m[strconv.Itoa(idx)])
|
||||
}
|
||||
*l = out
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("115: multitrack_list 既不是对象也不是数组")
|
||||
}
|
||||
|
||||
// VideoURLItem 是 115 返回的一条清晰度播放地址。
|
||||
type VideoURLItem struct {
|
||||
URL string `json:"url"`
|
||||
Height int `json:"height"`
|
||||
Width int `json:"width"`
|
||||
Definition int `json:"definition"`
|
||||
Title string `json:"title"`
|
||||
DefinitionN int `json:"definition_n"`
|
||||
}
|
||||
|
||||
// VideoPlayData 是 /open/video/play 的 data 字段。
|
||||
type VideoPlayData struct {
|
||||
FileID string `json:"file_id"`
|
||||
ParentID string `json:"parent_id"`
|
||||
FileName string `json:"file_name"`
|
||||
FileSize FlexString `json:"file_size"`
|
||||
FileSha1 string `json:"file_sha1"`
|
||||
FileType string `json:"file_type"`
|
||||
IsPrivate FlexString `json:"is_private"`
|
||||
PlayLong FlexString `json:"play_long"`
|
||||
UserDef int `json:"user_def"`
|
||||
UserRotate int `json:"user_rotate"`
|
||||
UserTurn int `json:"user_turn"`
|
||||
MultitrackList MultiTrackList `json:"multitrack_list"`
|
||||
DefinitionList map[string]string `json:"definition_list"`
|
||||
DefinitionListNew map[string]string `json:"definition_list_new"`
|
||||
VideoURL []VideoURLItem `json:"video_url"`
|
||||
VideoPushState *bool `json:"video_push_state"`
|
||||
}
|
||||
|
||||
// GetVideoPlayInfo 调用 115 视频在线播放接口。
|
||||
//
|
||||
// 注意:state=false(例如视频尚未转码)时 doAuthJSON 会返回 error,但仍会
|
||||
// 返回已解析的 RespBase/VideoPlayData。调用方需要同时检查两者:有 data 时
|
||||
// 可用于判断需要等待还是触发加速转码。
|
||||
func (c *OpenClient) GetVideoPlayInfo(ctx context.Context, pickCode, ua string) (*RespBase, *VideoPlayData, error) {
|
||||
pickCode = strings.TrimSpace(pickCode)
|
||||
if pickCode == "" {
|
||||
return nil, nil, fmt.Errorf("115: pick_code 为空")
|
||||
}
|
||||
resp, err := c.doAuthJSONWithUA(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
ProAPIBase+"/open/video/play",
|
||||
map[string]string{"pick_code": pickCode},
|
||||
1,
|
||||
ua,
|
||||
)
|
||||
if resp == nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
data := &VideoPlayData{}
|
||||
if len(resp.Data) > 0 {
|
||||
trimmed := bytes.TrimSpace(resp.Data)
|
||||
if !bytes.Equal(trimmed, []byte("null")) {
|
||||
if decodeErr := json.Unmarshal(trimmed, data); decodeErr != nil && err == nil {
|
||||
err = fmt.Errorf("115: 解析视频播放信息失败:%w", decodeErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return resp, data, err
|
||||
}
|
||||
|
||||
// SubmitVideoPush 提交 115 加速转码请求。
|
||||
//
|
||||
// op 支持 vip_push(按 VIP 等级加速)和 pay_push(消耗枫币)。调用方必须
|
||||
// 明确选择 op,默认空值按 vip_push 处理,避免误消费枫币。
|
||||
func (c *OpenClient) SubmitVideoPush(ctx context.Context, pickCode, op string) error {
|
||||
pickCode = strings.TrimSpace(pickCode)
|
||||
if pickCode == "" {
|
||||
return fmt.Errorf("115: pick_code 为空")
|
||||
}
|
||||
op = strings.TrimSpace(op)
|
||||
if op == "" {
|
||||
op = "vip_push"
|
||||
}
|
||||
_, err := c.doAuthJSON(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
ProAPIBase+"/open/video/video_push",
|
||||
map[string]string{
|
||||
"pick_code": pickCode,
|
||||
"op": op,
|
||||
},
|
||||
1,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCloud115HLSSessionNotFound = errors.New("115 hls session not found")
|
||||
ErrCloud115HLSUpstreamExpired = errors.New("115 hls upstream expired")
|
||||
)
|
||||
|
||||
const (
|
||||
cloud115HLSSessionTTL = 30 * time.Minute
|
||||
cloud115HLSMaxSessions = 256
|
||||
cloud115HLSMaxManifest = 8 << 20
|
||||
)
|
||||
|
||||
type cloud115HLSSession struct {
|
||||
ID string
|
||||
MediaID string
|
||||
Definition int
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
entries map[string]string
|
||||
next int
|
||||
}
|
||||
|
||||
// Cloud115HLSProxy 把 115 云端 HLS 转成 MeBox 同源 HLS。
|
||||
//
|
||||
// 浏览器不能直接请求 115 的 m3u8:master/variant/分片的 CORS 只允许
|
||||
// https://115.com,且 master 还是 HTTP。代理在服务端拉取并重写播放列表,
|
||||
// 分片按 Range 流式转发。
|
||||
type Cloud115HLSProxy struct {
|
||||
service *Cloud115PlaybackService
|
||||
client *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]*cloud115HLSSession
|
||||
}
|
||||
|
||||
func newCloud115HLSProxy(service *Cloud115PlaybackService) *Cloud115HLSProxy {
|
||||
return &Cloud115HLSProxy{
|
||||
service: service,
|
||||
client: &http.Client{
|
||||
Timeout: 0,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 6 {
|
||||
return errors.New("stopped after 6 redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
sessions: make(map[string]*cloud115HLSSession),
|
||||
}
|
||||
}
|
||||
|
||||
// ServeMaster 解析指定清晰度并返回重写后的 master.m3u8。
|
||||
func (p *Cloud115HLSProxy) ServeMaster(ctx context.Context, w http.ResponseWriter, r *http.Request, mediaID string, definition int) error {
|
||||
if p == nil || p.service == nil {
|
||||
return ErrCloud115NotApplicable
|
||||
}
|
||||
upstream, _, err := p.service.ResolveCloud115URL(ctx, mediaID, definition)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
session := p.newSession(mediaID, definition)
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
resp, err := p.fetchUpstream(fetchCtx, upstream, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("115 云端播放列表返回 HTTP %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, cloud115HLSMaxManifest))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
baseURL := upstream
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
baseURL = resp.Request.URL.String()
|
||||
}
|
||||
rewritten := p.rewriteManifest(session, string(body), baseURL, r.URL.RawQuery)
|
||||
p.storeSession(session)
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
||||
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(rewritten)))
|
||||
if r.Method != http.MethodHead {
|
||||
_, err = io.WriteString(w, rewritten)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ServeChild 代理 variant/分片;variant 播放列表会继续重写为同源地址。
|
||||
func (p *Cloud115HLSProxy) ServeChild(ctx context.Context, w http.ResponseWriter, r *http.Request, sessionID, key string) error {
|
||||
session, upstream, ok := p.lookup(sessionID, key)
|
||||
if !ok {
|
||||
return ErrCloud115HLSSessionNotFound
|
||||
}
|
||||
resp, err := p.fetchUpstream(ctx, upstream, r.Header.Get("Range"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusGone {
|
||||
p.deleteSession(sessionID)
|
||||
return ErrCloud115HLSUpstreamExpired
|
||||
}
|
||||
|
||||
contentType := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Type")))
|
||||
isPlaylist := strings.Contains(contentType, "mpegurl") ||
|
||||
strings.Contains(contentType, "application/vnd.apple.mpegurl")
|
||||
|
||||
if !isPlaylist {
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(string(body)), "#EXTM3U") {
|
||||
isPlaylist = true
|
||||
resp.Body = io.NopCloser(io.MultiReader(strings.NewReader(string(body)), resp.Body))
|
||||
} else {
|
||||
resp.Body = io.NopCloser(io.MultiReader(strings.NewReader(string(body)), resp.Body))
|
||||
}
|
||||
}
|
||||
|
||||
copyUpstreamHeaders(w, resp, isPlaylist)
|
||||
if isPlaylist {
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, cloud115HLSMaxManifest))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
baseURL := upstream
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
baseURL = resp.Request.URL.String()
|
||||
}
|
||||
rewritten := p.rewriteManifest(session, string(body), baseURL, r.URL.RawQuery)
|
||||
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
||||
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(rewritten)))
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
if r.Method != http.MethodHead {
|
||||
_, err = io.WriteString(w, rewritten)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
if r.Method != http.MethodHead {
|
||||
_, err = io.Copy(w, resp.Body)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *Cloud115HLSProxy) newSession(mediaID string, definition int) *cloud115HLSSession {
|
||||
now := time.Now()
|
||||
return &cloud115HLSSession{
|
||||
ID: cloud115.RandomString(24),
|
||||
MediaID: mediaID,
|
||||
Definition: definition,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(cloud115HLSSessionTTL),
|
||||
entries: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Cloud115HLSProxy) storeSession(session *cloud115HLSSession) {
|
||||
if p == nil || session == nil {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
now := time.Now()
|
||||
if len(p.sessions) >= cloud115HLSMaxSessions {
|
||||
for id, existing := range p.sessions {
|
||||
if now.After(existing.ExpiresAt) {
|
||||
delete(p.sessions, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(p.sessions) >= cloud115HLSMaxSessions {
|
||||
for id := range p.sessions {
|
||||
delete(p.sessions, id)
|
||||
break
|
||||
}
|
||||
}
|
||||
session.ExpiresAt = now.Add(cloud115HLSSessionTTL)
|
||||
p.sessions[session.ID] = session
|
||||
}
|
||||
|
||||
func (p *Cloud115HLSProxy) lookup(sessionID, key string) (*cloud115HLSSession, string, bool) {
|
||||
if p == nil {
|
||||
return nil, "", false
|
||||
}
|
||||
p.mu.Lock()
|
||||
session := p.sessions[sessionID]
|
||||
if session != nil && time.Now().After(session.ExpiresAt) {
|
||||
delete(p.sessions, sessionID)
|
||||
session = nil
|
||||
}
|
||||
p.mu.Unlock()
|
||||
if session == nil {
|
||||
return nil, "", false
|
||||
}
|
||||
session.mu.Lock()
|
||||
upstream, ok := session.entries[key]
|
||||
session.mu.Unlock()
|
||||
return session, upstream, ok
|
||||
}
|
||||
|
||||
func (p *Cloud115HLSProxy) deleteSession(sessionID string) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
delete(p.sessions, sessionID)
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *Cloud115HLSProxy) fetchUpstream(ctx context.Context, rawURL, rangeHeader string) (*http.Response, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || parsed == nil || !isAllowed115UpstreamHost(parsed.Hostname()) {
|
||||
return nil, fmt.Errorf("115 hls upstream host not allowed: %s", rawURL)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", cloud115.DefaultUA)
|
||||
if strings.TrimSpace(rangeHeader) != "" {
|
||||
req.Header.Set("Range", rangeHeader)
|
||||
}
|
||||
return p.client.Do(req)
|
||||
}
|
||||
|
||||
func isAllowed115UpstreamHost(host string) bool {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
for _, suffix := range []string{".115.com", ".115cdn.com", ".115cdn.net"} {
|
||||
if strings.HasSuffix(host, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return host == "115.com" || host == "115cdn.com" || host == "115cdn.net"
|
||||
}
|
||||
|
||||
func (p *Cloud115HLSProxy) rewriteManifest(session *cloud115HLSSession, text, baseURL, rawQuery string) string {
|
||||
if session == nil {
|
||||
return text
|
||||
}
|
||||
lines := strings.SplitAfter(text, "\n")
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "#") {
|
||||
if strings.HasPrefix(trimmed, "#EXT-X-MEDIA:") ||
|
||||
strings.HasPrefix(trimmed, "#EXT-X-KEY:") ||
|
||||
strings.HasPrefix(trimmed, "#EXT-X-MAP:") {
|
||||
lines[i] = replaceManifestURI(line, func(uri string) string {
|
||||
return p.proxyURL(session, resolveManifestURL(baseURL, uri), rawQuery)
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
lines[i] = p.proxyURL(session, resolveManifestURL(baseURL, trimmed), rawQuery) + lineEnding(line)
|
||||
}
|
||||
return strings.Join(lines, "")
|
||||
}
|
||||
|
||||
func (p *Cloud115HLSProxy) proxyURL(session *cloud115HLSSession, upstream, rawQuery string) string {
|
||||
session.mu.Lock()
|
||||
session.next++
|
||||
key := "e" + strconv.Itoa(session.next)
|
||||
session.entries[key] = upstream
|
||||
mediaID := session.MediaID
|
||||
session.mu.Unlock()
|
||||
|
||||
query := childProxyQuery(rawQuery, mediaID)
|
||||
return "/api/cloud115/hls/" + url.PathEscape(session.ID) + "/" + url.PathEscape(key) + "?" + query
|
||||
}
|
||||
|
||||
func childProxyQuery(rawQuery, mediaID string) string {
|
||||
values, _ := url.ParseQuery(rawQuery)
|
||||
keep := url.Values{}
|
||||
for _, key := range []string{"token", "api_key", "apiKey", "ApiKey", "profile_id", "profile_pin_token"} {
|
||||
if value := strings.TrimSpace(values.Get(key)); value != "" {
|
||||
keep.Set(key, value)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(mediaID) != "" {
|
||||
keep.Set("media_id", mediaID)
|
||||
}
|
||||
return keep.Encode()
|
||||
}
|
||||
|
||||
func replaceManifestURI(line string, replace func(string) string) string {
|
||||
const marker = `URI="`
|
||||
idx := strings.Index(line, marker)
|
||||
if idx < 0 {
|
||||
return line
|
||||
}
|
||||
start := idx + len(marker)
|
||||
end := strings.Index(line[start:], `"`)
|
||||
if end < 0 {
|
||||
return line
|
||||
}
|
||||
end += start
|
||||
return line[:start] + replace(line[start:end]) + line[end:]
|
||||
}
|
||||
|
||||
func resolveManifestURL(baseURL, raw string) string {
|
||||
base, baseErr := url.Parse(strings.TrimSpace(baseURL))
|
||||
ref, refErr := url.Parse(strings.TrimSpace(raw))
|
||||
if baseErr != nil || refErr != nil || base == nil || ref == nil {
|
||||
return raw
|
||||
}
|
||||
return base.ResolveReference(ref).String()
|
||||
}
|
||||
|
||||
func lineEnding(line string) string {
|
||||
if strings.HasSuffix(line, "\r\n") {
|
||||
return "\r\n"
|
||||
}
|
||||
if strings.HasSuffix(line, "\n") {
|
||||
return "\n"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func copyUpstreamHeaders(w http.ResponseWriter, resp *http.Response, playlist bool) {
|
||||
for _, key := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified"} {
|
||||
if value := resp.Header.Get(key); value != "" {
|
||||
w.Header().Set(key, value)
|
||||
}
|
||||
}
|
||||
if playlist {
|
||||
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||
} else if w.Header().Get("Cache-Control") == "" {
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCloud115HLSProxyRewriteManifest(t *testing.T) {
|
||||
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
|
||||
session := &cloud115HLSSession{
|
||||
ID: "sess",
|
||||
MediaID: "media-1",
|
||||
entries: map[string]string{},
|
||||
}
|
||||
manifest := "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1800000,RESOLUTION=1280x720\nhttps://cpats01.115.com/a.m3u8?u=1&se=2\n"
|
||||
rewritten := proxy.rewriteManifest(session, manifest, "http://videoplay.115.com/m3u8/pc", "token=t&media_id=media-1")
|
||||
if strings.Contains(rewritten, "cpats01.115.com") {
|
||||
t.Fatalf("upstream URL leaked into rewritten manifest: %s", rewritten)
|
||||
}
|
||||
if !strings.Contains(rewritten, "/api/cloud115/hls/sess/e1?") {
|
||||
t.Fatalf("proxy URL missing: %s", rewritten)
|
||||
}
|
||||
if !strings.Contains(rewritten, "media_id=media-1") || !strings.Contains(rewritten, "token=t") {
|
||||
t.Fatalf("auth/media query missing: %s", rewritten)
|
||||
}
|
||||
if len(session.entries) != 1 {
|
||||
t.Fatalf("session entries = %d, want 1", len(session.entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloud115HLSProxyRewriteKeyURI(t *testing.T) {
|
||||
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
|
||||
session := &cloud115HLSSession{
|
||||
ID: "sess",
|
||||
MediaID: "media-1",
|
||||
entries: map[string]string{},
|
||||
}
|
||||
manifest := `#EXTM3U
|
||||
#EXT-X-KEY:METHOD=AES-128,URI="https://cpats01.115.com/key?k=1"
|
||||
#EXTINF:10.0,
|
||||
https://cpats01.115.com/seg.ts?x=1
|
||||
`
|
||||
rewritten := proxy.rewriteManifest(session, manifest, "https://cpats01.115.com/v.m3u8", "token=t&media_id=media-1")
|
||||
if strings.Contains(rewritten, "cpats01.115.com") {
|
||||
t.Fatalf("upstream URL leaked into rewritten key manifest: %s", rewritten)
|
||||
}
|
||||
if len(session.entries) != 2 {
|
||||
t.Fatalf("session entries = %d, want 2", len(session.entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAllowed115UpstreamHost(t *testing.T) {
|
||||
for _, host := range []string{"videoplay.115.com", "cpats01.115.com", "cdn.115cdn.net"} {
|
||||
if !isAllowed115UpstreamHost(host) {
|
||||
t.Fatalf("host %q should be allowed", host)
|
||||
}
|
||||
}
|
||||
for _, host := range []string{"", "evil.example.com", "115.com.evil.example.com"} {
|
||||
if isAllowed115UpstreamHost(host) {
|
||||
t.Fatalf("host %q should be rejected", host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloud115HLSProxyServeChildRewritesVariant(t *testing.T) {
|
||||
proxy := &Cloud115HLSProxy{
|
||||
sessions: map[string]*cloud115HLSSession{},
|
||||
}
|
||||
proxy.client = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
body := "#EXTM3U\n#EXT-X-TARGETDURATION:10\nhttps://cpats01.115.com/seg.ts?x=1\n"
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/vnd.apple.mpegurl"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
session := &cloud115HLSSession{
|
||||
ID: "sess",
|
||||
MediaID: "media-1",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
entries: map[string]string{"e1": "https://cpats01.115.com/v.m3u8"},
|
||||
next: 1,
|
||||
}
|
||||
proxy.sessions[session.ID] = session
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/sess/e1?media_id=media-1&token=t", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
if err := proxy.ServeChild(context.Background(), rec, req, session.ID, "e1"); err != nil {
|
||||
t.Fatalf("ServeChild: %v", err)
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "/api/cloud115/hls/sess/e2?") {
|
||||
t.Fatalf("rewritten variant missing proxy segment: %s", rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "cpats01.115.com") {
|
||||
t.Fatalf("upstream URL leaked in variant: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloud115HLSProxyServeChildStreamsRange(t *testing.T) {
|
||||
payload := []byte("segment-bytes")
|
||||
proxy := &Cloud115HLSProxy{
|
||||
sessions: map[string]*cloud115HLSSession{},
|
||||
}
|
||||
proxy.client = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusPartialContent,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"video/mp2t"},
|
||||
"Content-Range": []string{"bytes 0-12/100"},
|
||||
"Content-Length": []string{strconv.Itoa(len(payload))},
|
||||
},
|
||||
Body: io.NopCloser(bytes.NewReader(payload)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
session := &cloud115HLSSession{
|
||||
ID: "sess",
|
||||
MediaID: "media-1",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
entries: map[string]string{"e1": "https://cpats01.115.com/seg.ts?x=1"},
|
||||
next: 1,
|
||||
}
|
||||
proxy.sessions[session.ID] = session
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/sess/e1?media_id=media-1&token=t", nil)
|
||||
req.Header.Set("Range", "bytes=0-12")
|
||||
rec := httptest.NewRecorder()
|
||||
if err := proxy.ServeChild(context.Background(), rec, req, session.ID, "e1"); err != nil {
|
||||
t.Fatalf("ServeChild: %v", err)
|
||||
}
|
||||
if rec.Code != http.StatusPartialContent {
|
||||
t.Fatalf("status = %d, want 206", rec.Code)
|
||||
}
|
||||
if rec.Header().Get("Content-Range") != "bytes 0-12/100" {
|
||||
t.Fatalf("content-range = %q", rec.Header().Get("Content-Range"))
|
||||
}
|
||||
if !bytes.Equal(rec.Body.Bytes(), payload) {
|
||||
t.Fatalf("payload = %q, want %q", rec.Body.Bytes(), payload)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCloud115TranscodePending = errors.New("115 cloud transcode pending")
|
||||
ErrCloud115NotApplicable = errors.New("115 cloud playback not applicable")
|
||||
)
|
||||
|
||||
// Cloud115TranscodeState 描述当前请求清晰度的云端转码状态。
|
||||
type Cloud115TranscodeState struct {
|
||||
State string `json:"state"` // idle / ready / transcoding / unavailable
|
||||
Definition string `json:"definition,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
RetryAfterSec int `json:"retry_after_sec,omitempty"`
|
||||
StartedAt int64 `json:"started_at,omitempty"`
|
||||
}
|
||||
|
||||
// PlaybackInfo 是播放器加载时获取的统一播放能力描述。
|
||||
type PlaybackInfo struct {
|
||||
MediaID string `json:"media_id"`
|
||||
Provider string `json:"provider"`
|
||||
Fallback []string `json:"fallback"`
|
||||
DefaultQuality string `json:"default_quality"`
|
||||
CloudQualities []PlaybackQuality `json:"cloud_qualities,omitempty"`
|
||||
LocalQualities []PlaybackQuality `json:"local_qualities"`
|
||||
Transcode Cloud115TranscodeState `json:"transcode"`
|
||||
}
|
||||
|
||||
type cloud115PushAttempt struct {
|
||||
at time.Time
|
||||
success bool
|
||||
message string
|
||||
}
|
||||
|
||||
// Cloud115PlaybackService 负责 115 云端播放、清晰度列表和云端转码触发。
|
||||
// 实际的 HLS 代理在 Cloud115HLSProxy 中。
|
||||
type Cloud115PlaybackService struct {
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
strm *StrmService
|
||||
|
||||
mu sync.Mutex
|
||||
pushState map[string]cloud115PushAttempt
|
||||
|
||||
proxy *Cloud115HLSProxy
|
||||
}
|
||||
|
||||
func NewCloud115PlaybackService(cfg *config.Config, log *zap.Logger, repo *repository.Container, strm *StrmService) *Cloud115PlaybackService {
|
||||
svc := &Cloud115PlaybackService{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
repo: repo,
|
||||
strm: strm,
|
||||
pushState: make(map[string]cloud115PushAttempt),
|
||||
}
|
||||
svc.proxy = newCloud115HLSProxy(svc)
|
||||
return svc
|
||||
}
|
||||
|
||||
// HLSProxy 返回 115 云 HLS 反向代理。
|
||||
func (s *Cloud115PlaybackService) HLSProxy() *Cloud115HLSProxy {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.proxy
|
||||
}
|
||||
|
||||
// PlaybackInfo 返回媒体在 direct/115-cloud/local 三种模式下的能力描述。
|
||||
//
|
||||
// requestedDefinition 为空或 0 时默认选择 1080P;该档位不可用时返回
|
||||
// transcoding 状态(并只触发一次加速转码,避免轮询风暴)。
|
||||
func (s *Cloud115PlaybackService) PlaybackInfo(ctx context.Context, mediaID string, requestedDefinition int) (*PlaybackInfo, error) {
|
||||
if s == nil || s.repo == nil || s.repo.Media == nil {
|
||||
return nil, ErrMediaNotFound
|
||||
}
|
||||
m, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m == nil {
|
||||
return nil, ErrMediaNotFound
|
||||
}
|
||||
|
||||
provider := MediaPlaybackProvider(m)
|
||||
localQualities := LocalQualityOptions(m)
|
||||
localDefault := DefaultLocalHLSQualityID(m)
|
||||
info := &PlaybackInfo{
|
||||
MediaID: m.ID,
|
||||
Provider: provider,
|
||||
DefaultQuality: localDefault,
|
||||
LocalQualities: localQualities,
|
||||
Transcode: Cloud115TranscodeState{State: "idle"},
|
||||
}
|
||||
if provider != model.StrmProvider115 {
|
||||
info.Fallback = []string{"direct", "local_hls"}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
account, pickCode, client, err := s.resolve115Target(ctx, m)
|
||||
if err != nil {
|
||||
info.Fallback = []string{"direct", "local_hls"}
|
||||
info.Transcode = Cloud115TranscodeState{State: "unavailable", Message: err.Error()}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
resp, data, playErr := client.GetVideoPlayInfo(ctx, pickCode, cloud115.DefaultUA)
|
||||
if cloud115PlayInfoFatal(resp, playErr) {
|
||||
info.Fallback = []string{"direct", "local_hls"}
|
||||
info.Transcode = Cloud115TranscodeState{
|
||||
State: "unavailable",
|
||||
Message: cloud115PlayErrorMessage(playErr, resp),
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
cloudQualities := Cloud115QualityOptions(m, data)
|
||||
info.CloudQualities = cloudQualities
|
||||
info.Fallback = []string{"direct", "cloud_hls", "local_hls"}
|
||||
info.DefaultQuality = DefaultCloud115Quality(cloudQualities)
|
||||
|
||||
requested := strings.TrimSpace(strconv.Itoa(requestedDefinition))
|
||||
if requestedDefinition <= 0 || requested == "0" {
|
||||
requested = info.DefaultQuality
|
||||
}
|
||||
selected, ok := findPlaybackQuality(cloudQualities, requested)
|
||||
if !ok {
|
||||
info.Transcode = Cloud115TranscodeState{
|
||||
State: "unavailable",
|
||||
Definition: requested,
|
||||
Message: "115 不支持该清晰度",
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
if selected.Source == "original" {
|
||||
info.Transcode = Cloud115TranscodeState{State: "ready", Definition: selected.ID}
|
||||
return info, nil
|
||||
}
|
||||
if selected.Available {
|
||||
info.Transcode = Cloud115TranscodeState{State: "ready", Definition: selected.ID}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
info.Transcode = s.ensureCloudTranscode(ctx, account.ID, pickCode, selected, client)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// StartTranscode 显式触发某个清晰度的云端转码,返回最新状态。
|
||||
func (s *Cloud115PlaybackService) StartTranscode(ctx context.Context, mediaID string, definition int) (*PlaybackInfo, error) {
|
||||
return s.PlaybackInfo(ctx, mediaID, definition)
|
||||
}
|
||||
|
||||
// ResolveCloud115URL 返回指定清晰度的 115 云端 m3u8 地址。
|
||||
// 档位尚未转码完成时返回 ErrCloud115TranscodePending。
|
||||
func (s *Cloud115PlaybackService) ResolveCloud115URL(ctx context.Context, mediaID string, definition int) (string, *cloud115.VideoPlayData, error) {
|
||||
m, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if m == nil {
|
||||
return "", nil, ErrMediaNotFound
|
||||
}
|
||||
if MediaPlaybackProvider(m) != model.StrmProvider115 {
|
||||
return "", nil, ErrCloud115NotApplicable
|
||||
}
|
||||
_, pickCode, client, err := s.resolve115Target(ctx, m)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
resp, data, playErr := client.GetVideoPlayInfo(ctx, pickCode, cloud115.DefaultUA)
|
||||
if cloud115PlayInfoFatal(resp, playErr) {
|
||||
return "", data, fmt.Errorf("115 云端播放不可用:%s", cloud115PlayErrorMessage(playErr, resp))
|
||||
}
|
||||
requested := definition
|
||||
if requested <= 0 {
|
||||
requested = 4
|
||||
}
|
||||
item := findVideoURL(data, requested)
|
||||
if item == nil || strings.TrimSpace(item.URL) == "" {
|
||||
return "", data, ErrCloud115TranscodePending
|
||||
}
|
||||
return strings.TrimSpace(item.URL), data, nil
|
||||
}
|
||||
|
||||
func (s *Cloud115PlaybackService) ensureCloudTranscode(
|
||||
ctx context.Context,
|
||||
accountID, pickCode string,
|
||||
quality PlaybackQuality,
|
||||
client *cloud115.OpenClient,
|
||||
) Cloud115TranscodeState {
|
||||
key := accountID + ":" + pickCode
|
||||
now := time.Now()
|
||||
|
||||
s.mu.Lock()
|
||||
if last, ok := s.pushState[key]; ok {
|
||||
ttl := 2 * time.Minute
|
||||
if last.success {
|
||||
ttl = 3 * time.Hour
|
||||
}
|
||||
if now.Sub(last.at) < ttl {
|
||||
s.mu.Unlock()
|
||||
return Cloud115TranscodeState{
|
||||
State: "transcoding",
|
||||
Definition: quality.ID,
|
||||
Message: last.message,
|
||||
RetryAfterSec: 5,
|
||||
StartedAt: last.at.Unix(),
|
||||
}
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
err := client.SubmitVideoPush(ctx, pickCode, "vip_push")
|
||||
attempt := cloud115PushAttempt{at: now, success: err == nil}
|
||||
if err != nil {
|
||||
attempt.message = fmt.Sprintf("115 云端转码排队中(加速请求未成功:%s)", cloud115PlayErrorMessage(err, nil))
|
||||
} else {
|
||||
attempt.message = fmt.Sprintf("已触发 115 云端转码,正在等待 %s…", quality.Label)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.pushState[key] = attempt
|
||||
s.mu.Unlock()
|
||||
|
||||
return Cloud115TranscodeState{
|
||||
State: "transcoding",
|
||||
Definition: quality.ID,
|
||||
Message: attempt.message,
|
||||
RetryAfterSec: 5,
|
||||
StartedAt: now.Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Cloud115PlaybackService) resolve115Target(ctx context.Context, m *model.Media) (*model.StrmAccount, string, *cloud115.OpenClient, error) {
|
||||
if s == nil || s.strm == nil || s.repo == nil || s.repo.StrmAccount == nil {
|
||||
return nil, "", nil, ErrCloud115NotApplicable
|
||||
}
|
||||
raw := strings.TrimSpace(m.STRMURL)
|
||||
if raw == "" && strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") {
|
||||
parsed, err := readLocalSTRMTarget(m.Path)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
raw = strings.TrimSpace(parsed)
|
||||
}
|
||||
if raw == "" {
|
||||
return nil, "", nil, fmt.Errorf("缺少 STRM 播放目标")
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(u.Path), "/api/strm/play/cloud115/") &&
|
||||
!strings.Contains(strings.ToLower(u.Path), "/api/cloud/play/cloud115") {
|
||||
return nil, "", nil, ErrCloud115NotApplicable
|
||||
}
|
||||
accountID := strings.TrimSpace(u.Query().Get("acct"))
|
||||
if accountID == "" {
|
||||
return nil, "", nil, fmt.Errorf("缺少 115 账号 ID")
|
||||
}
|
||||
pickCode := strings.TrimSpace(u.Query().Get("pickcode"))
|
||||
if pickCode == "" {
|
||||
return nil, "", nil, fmt.Errorf("缺少 115 pickcode")
|
||||
}
|
||||
account, err := s.repo.StrmAccount.FindByID(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
if account == nil || !account.Enabled {
|
||||
return nil, "", nil, fmt.Errorf("115 账号不存在或已禁用")
|
||||
}
|
||||
if account.Provider != model.StrmProvider115 {
|
||||
return nil, "", nil, ErrCloud115NotApplicable
|
||||
}
|
||||
provider, err := s.strm.providerFor(ctx, account)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
openProvider, ok := provider.(cloud.OpenAPI115Provider)
|
||||
if !ok || openProvider.OpenClient() == nil {
|
||||
return nil, "", nil, ErrCloud115NotApplicable
|
||||
}
|
||||
return account, pickCode, openProvider.OpenClient(), nil
|
||||
}
|
||||
|
||||
// MediaPlaybackProvider 返回媒体行的播放提供方(用于前端降级顺序)。
|
||||
func MediaPlaybackProvider(m *model.Media) string {
|
||||
if m == nil {
|
||||
return model.StrmProviderLocal
|
||||
}
|
||||
raw := strings.TrimSpace(m.STRMURL)
|
||||
if raw == "" && strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") {
|
||||
if target, err := readLocalSTRMTarget(m.Path); err == nil {
|
||||
raw = strings.TrimSpace(target)
|
||||
}
|
||||
}
|
||||
if raw != "" {
|
||||
if u, err := url.Parse(raw); err == nil {
|
||||
path := strings.ToLower(u.Path)
|
||||
if idx := strings.Index(path, "/api/strm/play/"); idx >= 0 {
|
||||
rest := strings.Trim(path[idx+len("/api/strm/play/"):], "/")
|
||||
if provider, _, ok := strings.Cut(rest, "/"); ok && provider != "" {
|
||||
return provider
|
||||
}
|
||||
}
|
||||
if idx := strings.Index(path, "/api/cloud/play/"); idx >= 0 {
|
||||
provider := strings.Trim(path[idx+len("/api/cloud/play/"):], "/")
|
||||
if provider != "" {
|
||||
return provider
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if IsStrmMediaRow(m) {
|
||||
return "strm"
|
||||
}
|
||||
return model.StrmProviderLocal
|
||||
}
|
||||
|
||||
func findPlaybackQuality(options []PlaybackQuality, id string) (PlaybackQuality, bool) {
|
||||
id = strings.TrimSpace(id)
|
||||
for _, option := range options {
|
||||
if option.ID == id {
|
||||
return option, true
|
||||
}
|
||||
}
|
||||
return PlaybackQuality{}, false
|
||||
}
|
||||
|
||||
func findVideoURL(data *cloud115.VideoPlayData, definition int) *cloud115.VideoURLItem {
|
||||
if data == nil || definition <= 0 {
|
||||
return nil
|
||||
}
|
||||
for i := range data.VideoURL {
|
||||
item := &data.VideoURL[i]
|
||||
if item.DefinitionN == definition || item.Definition == definition {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloud115PlayInfoFatal(resp *cloud115.RespBase, err error) bool {
|
||||
if resp == nil {
|
||||
return true
|
||||
}
|
||||
if bool(resp.State) {
|
||||
return false
|
||||
}
|
||||
switch resp.Code {
|
||||
case cloud115.AccessTokenAuthFail,
|
||||
cloud115.AccessTokenExpiryCode,
|
||||
cloud115.AccessAuthInvalid,
|
||||
cloud115.AccessTokenFormatInvalid,
|
||||
cloud115.RefreshTokenInvalid,
|
||||
cloud115.TokenRefreshFail,
|
||||
cloud115.RequestMaxLimitCode,
|
||||
cloud115.RequestRateLimitCode:
|
||||
return true
|
||||
}
|
||||
// 非鉴权/限流错误一律当作“尚未转码完成”处理,继续轮询。
|
||||
_ = err
|
||||
return false
|
||||
}
|
||||
|
||||
func cloud115PlayErrorMessage(err error, resp *cloud115.RespBase) string {
|
||||
if err != nil {
|
||||
var apiErr *cloud115.OpenAPIError
|
||||
if errors.As(err, &apiErr) {
|
||||
switch apiErr.Code {
|
||||
case cloud115.AccessTokenAuthFail,
|
||||
cloud115.AccessTokenExpiryCode,
|
||||
cloud115.AccessAuthInvalid,
|
||||
cloud115.AccessTokenFormatInvalid,
|
||||
cloud115.RefreshTokenInvalid:
|
||||
return "115 授权已过期,请重新授权账号"
|
||||
}
|
||||
if strings.TrimSpace(apiErr.Message) != "" {
|
||||
return apiErr.Message
|
||||
}
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
if resp != nil {
|
||||
if msg := strings.TrimSpace(resp.Message); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if msg := strings.TrimSpace(resp.Error); msg != "" {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
return "115 云端播放不可用"
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||
)
|
||||
|
||||
func TestCloud115PlaybackInfoTriggersTranscodeOnce(t *testing.T) {
|
||||
var playCalls int32
|
||||
var pushCalls int32
|
||||
var ready int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/open/video/play":
|
||||
atomic.AddInt32(&playCalls, 1)
|
||||
if atomic.LoadInt32(&ready) == 0 {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"state": false,
|
||||
"code": 0,
|
||||
"message": "转码中",
|
||||
"data": map[string]any{},
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"state": true,
|
||||
"code": 0,
|
||||
"message": "",
|
||||
"data": map[string]any{
|
||||
"file_name": "test.mkv",
|
||||
"definition_list_new": map[string]string{
|
||||
"4": "1080P",
|
||||
},
|
||||
"video_url": []map[string]any{
|
||||
{
|
||||
"url": "http://cdn.example/master.m3u8",
|
||||
"definition": 4,
|
||||
"definition_n": 4,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"title": "1080P",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
case "/open/video/video_push":
|
||||
atomic.AddInt32(&pushCalls, 1)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"state": true, "code": 0, "data": []any{}})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldBase := cloud115.ProAPIBase
|
||||
cloud115.ProAPIBase = server.URL
|
||||
defer func() { cloud115.ProAPIBase = oldBase }()
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.Media{})
|
||||
repos := repository.New(db)
|
||||
strm := NewStrmService(nil, zap.NewNop(), repos, NewCryptoService("test-secret", zap.NewNop()))
|
||||
ctx := context.Background()
|
||||
account, err := strm.CreateStrmAccount(ctx, "115", model.StrmProvider115, map[string]string{
|
||||
"app_id": "100195129",
|
||||
"access_token": "at",
|
||||
"refresh_token": "rt",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
media := &model.Media{
|
||||
Base: model.Base{ID: "media-1"},
|
||||
Title: "test",
|
||||
Path: "/media/test.strm",
|
||||
Container: "strm",
|
||||
Height: 1080,
|
||||
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=" + account.ID + "&pickcode=pc",
|
||||
}
|
||||
if err := db.Create(media).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
svc := NewCloud115PlaybackService(nil, zap.NewNop(), repos, strm)
|
||||
|
||||
info, err := svc.PlaybackInfo(ctx, media.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("first playback info: %v", err)
|
||||
}
|
||||
if info.Provider != model.StrmProvider115 || info.DefaultQuality != "4" {
|
||||
t.Fatalf("unexpected playback info: %#v", info)
|
||||
}
|
||||
if info.Transcode.State != "transcoding" {
|
||||
t.Fatalf("transcode state = %q, want transcoding", info.Transcode.State)
|
||||
}
|
||||
if got := atomic.LoadInt32(&pushCalls); got != 1 {
|
||||
t.Fatalf("push calls = %d, want 1", got)
|
||||
}
|
||||
|
||||
if _, err := svc.PlaybackInfo(ctx, media.ID, 0); err != nil {
|
||||
t.Fatalf("second playback info: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&pushCalls); got != 1 {
|
||||
t.Fatalf("push calls after polling = %d, want 1", got)
|
||||
}
|
||||
|
||||
atomic.StoreInt32(&ready, 1)
|
||||
info, err = svc.PlaybackInfo(ctx, media.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ready playback info: %v", err)
|
||||
}
|
||||
if info.Transcode.State != "ready" {
|
||||
t.Fatalf("transcode state = %q, want ready", info.Transcode.State)
|
||||
}
|
||||
url, _, err := svc.ResolveCloud115URL(ctx, media.ID, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve cloud url: %v", err)
|
||||
}
|
||||
if url != "http://cdn.example/master.m3u8" {
|
||||
t.Fatalf("url = %q", url)
|
||||
}
|
||||
if got := atomic.LoadInt32(&playCalls); got < 3 {
|
||||
t.Fatalf("play calls = %d, want >= 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloud115PlaybackInfoTranscodePendingBeforeReady(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/open/video/play":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"state": false, "code": 0, "message": "转码中", "data": map[string]any{}})
|
||||
case "/open/video/video_push":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"state": true, "code": 0, "data": []any{}})
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
oldBase := cloud115.ProAPIBase
|
||||
cloud115.ProAPIBase = server.URL
|
||||
defer func() { cloud115.ProAPIBase = oldBase }()
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.Media{})
|
||||
repos := repository.New(db)
|
||||
strm := NewStrmService(nil, zap.NewNop(), repos, NewCryptoService("test-secret", zap.NewNop()))
|
||||
ctx := context.Background()
|
||||
account, err := strm.CreateStrmAccount(ctx, "115", model.StrmProvider115, map[string]string{
|
||||
"app_id": "100195129",
|
||||
"access_token": "at",
|
||||
"refresh_token": "rt",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
media := &model.Media{
|
||||
Base: model.Base{ID: "media-2"},
|
||||
Title: "test",
|
||||
Path: "/media/test2.strm",
|
||||
Container: "strm",
|
||||
Height: 1080,
|
||||
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=" + account.ID + "&pickcode=pc2",
|
||||
}
|
||||
if err := db.Create(media).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
svc := NewCloud115PlaybackService(nil, zap.NewNop(), repos, strm)
|
||||
if _, _, err := svc.ResolveCloud115URL(ctx, media.ID, 4); !errors.Is(err, ErrCloud115TranscodePending) {
|
||||
t.Fatalf("resolve pending err = %v, want ErrCloud115TranscodePending", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaPlaybackProvider(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{"115", "/api/strm/play/cloud115/video.mkv?acct=1&pickcode=pc", model.StrmProvider115},
|
||||
{"openlist", "/api/strm/play/openlist/video.mkv?acct=1&ref=/a.mkv", model.StrmProviderOpenList},
|
||||
{"local", "/media/local.mkv", model.StrmProviderLocal},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
media := &model.Media{Path: tc.raw}
|
||||
if tc.name != "local" {
|
||||
media.STRMURL = tc.raw
|
||||
}
|
||||
got := MediaPlaybackProvider(media)
|
||||
if got != tc.want {
|
||||
t.Fatalf("provider = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||
)
|
||||
|
||||
// PlaybackQuality 是返回给播放器的统一画质选项。
|
||||
//
|
||||
// Source:
|
||||
// - cloud: 115 云端转码清晰度
|
||||
// - original: 115 原文件(直连,可能仍是 MKV/HEVC/PGS)
|
||||
// - local: MeBox 本地 HLS 重新编码清晰度
|
||||
type PlaybackQuality struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Source string `json:"source"`
|
||||
Available bool `json:"available"`
|
||||
RequiresTranscode bool `json:"requires_transcode,omitempty"`
|
||||
RequiresVIP bool `json:"requires_vip,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
type cloud115QualitySpec struct {
|
||||
ID string
|
||||
Label string
|
||||
Height int
|
||||
VIP bool
|
||||
}
|
||||
|
||||
var cloud115QualityLadder = []cloud115QualitySpec{
|
||||
{ID: "1", Label: "标清", Height: 480},
|
||||
{ID: "2", Label: "高清", Height: 540},
|
||||
{ID: "3", Label: "超清", Height: 720},
|
||||
{ID: "4", Label: "1080P", Height: 1080},
|
||||
{ID: "5", Label: "4K", Height: 2160, VIP: true},
|
||||
}
|
||||
|
||||
var resolutionTokenRE = regexp.MustCompile(`(?i)(\d{3,4})p`)
|
||||
|
||||
func mediaSourceHeight(m *model.Media) int {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
if m.Height > 0 {
|
||||
return m.Height
|
||||
}
|
||||
for _, text := range []string{m.OriginalName, m.Path, m.STRMURL} {
|
||||
if h := parseHeightToken(text); h > 0 {
|
||||
return h
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseHeightToken(text string) int {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return 0
|
||||
}
|
||||
upper := strings.ToUpper(text)
|
||||
if strings.Contains(upper, "4K") || strings.Contains(upper, "2160P") {
|
||||
return 2160
|
||||
}
|
||||
for _, match := range resolutionTokenRE.FindAllStringSubmatch(text, -1) {
|
||||
if len(match) < 2 {
|
||||
continue
|
||||
}
|
||||
height, err := strconv.Atoi(match[1])
|
||||
if err != nil || height < 240 || height > 4320 {
|
||||
continue
|
||||
}
|
||||
return height
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func cloud115AvailableDefinitions(data *cloud115.VideoPlayData) map[string]bool {
|
||||
available := map[string]bool{}
|
||||
if data == nil {
|
||||
return available
|
||||
}
|
||||
for key := range data.DefinitionListNew {
|
||||
key = strings.TrimSpace(key)
|
||||
if key != "" {
|
||||
available[key] = true
|
||||
}
|
||||
}
|
||||
for key := range data.DefinitionList {
|
||||
key = strings.TrimSpace(key)
|
||||
if key != "" {
|
||||
available[key] = true
|
||||
}
|
||||
}
|
||||
for _, item := range data.VideoURL {
|
||||
id := item.DefinitionN
|
||||
if id <= 0 {
|
||||
id = item.Definition
|
||||
}
|
||||
if id > 0 {
|
||||
available[strconv.Itoa(id)] = true
|
||||
}
|
||||
}
|
||||
return available
|
||||
}
|
||||
|
||||
func cloud115SourceHeight(m *model.Media, data *cloud115.VideoPlayData) int {
|
||||
if height := mediaSourceHeight(m); height > 0 {
|
||||
return height
|
||||
}
|
||||
maxHeight := 0
|
||||
if data != nil {
|
||||
for _, item := range data.VideoURL {
|
||||
if item.Height > maxHeight {
|
||||
maxHeight = item.Height
|
||||
}
|
||||
}
|
||||
}
|
||||
if maxHeight > 0 {
|
||||
return maxHeight
|
||||
}
|
||||
return 1080
|
||||
}
|
||||
|
||||
// Cloud115QualityOptions 返回 115 已转码和“按源分辨率可转码”的清晰度。
|
||||
// 未出现在 definition_list_new/video_url 中的档位会标记为需要云端转码。
|
||||
func Cloud115QualityOptions(m *model.Media, data *cloud115.VideoPlayData) []PlaybackQuality {
|
||||
sourceHeight := cloud115SourceHeight(m, data)
|
||||
available := cloud115AvailableDefinitions(data)
|
||||
out := make([]PlaybackQuality, 0, len(cloud115QualityLadder)+1)
|
||||
|
||||
out = append(out, PlaybackQuality{
|
||||
ID: "100",
|
||||
Label: "原画",
|
||||
Height: sourceHeight,
|
||||
Source: "original",
|
||||
Available: true,
|
||||
Note: "播放原始文件,可能仍是 MKV/HEVC/PGS",
|
||||
})
|
||||
|
||||
for _, spec := range cloud115QualityLadder {
|
||||
if sourceHeight > 0 && spec.Height > sourceHeight && !available[spec.ID] {
|
||||
continue
|
||||
}
|
||||
label := spec.Label
|
||||
if data != nil && strings.TrimSpace(data.DefinitionListNew[spec.ID]) != "" {
|
||||
label = strings.TrimSpace(data.DefinitionListNew[spec.ID])
|
||||
}
|
||||
isAvailable := available[spec.ID]
|
||||
item := PlaybackQuality{
|
||||
ID: spec.ID,
|
||||
Label: label,
|
||||
Height: spec.Height,
|
||||
Source: "cloud",
|
||||
Available: isAvailable,
|
||||
RequiresTranscode: !isAvailable,
|
||||
RequiresVIP: spec.VIP,
|
||||
}
|
||||
if !isAvailable {
|
||||
item.Note = "需要 115 云端转码"
|
||||
}
|
||||
if spec.VIP {
|
||||
if item.Note != "" {
|
||||
item.Note += ","
|
||||
}
|
||||
item.Note += "需要年费 VIP"
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Source != out[j].Source {
|
||||
return out[i].Source == "original"
|
||||
}
|
||||
if out[i].Height != out[j].Height {
|
||||
return out[i].Height > out[j].Height
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// DefaultCloud115Quality 默认优先 1080P;没有 1080P 时退回最高的云端档位。
|
||||
func DefaultCloud115Quality(options []PlaybackQuality) string {
|
||||
for _, option := range options {
|
||||
if option.ID == "4" && option.Source == "cloud" {
|
||||
return option.ID
|
||||
}
|
||||
}
|
||||
best := ""
|
||||
bestHeight := -1
|
||||
for _, option := range options {
|
||||
if option.Source != "cloud" {
|
||||
continue
|
||||
}
|
||||
if option.Height > bestHeight {
|
||||
best = option.ID
|
||||
bestHeight = option.Height
|
||||
}
|
||||
}
|
||||
if best != "" {
|
||||
return best
|
||||
}
|
||||
return "4"
|
||||
}
|
||||
|
||||
// LocalQualityOptions 返回 MeBox 本地 HLS 可用的画质档位。
|
||||
func LocalQualityOptions(m *model.Media) []PlaybackQuality {
|
||||
sourceHeight := mediaSourceHeight(m)
|
||||
presets := []PlaybackQuality{
|
||||
{ID: "source", Label: "原画", Height: sourceHeight, Source: "local", Available: true, Note: "本地 HLS,保持源分辨率"},
|
||||
{ID: "1080", Label: "1080P", Height: 1080, Source: "local", Available: true},
|
||||
{ID: "720", Label: "720P", Height: 720, Source: "local", Available: true},
|
||||
{ID: "480", Label: "480P", Height: 480, Source: "local", Available: true},
|
||||
}
|
||||
out := make([]PlaybackQuality, 0, len(presets))
|
||||
for _, preset := range presets {
|
||||
if preset.ID != "source" && sourceHeight > 0 && preset.Height > sourceHeight {
|
||||
continue
|
||||
}
|
||||
out = append(out, preset)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].ID == "source" {
|
||||
return true
|
||||
}
|
||||
if out[j].ID == "source" {
|
||||
return false
|
||||
}
|
||||
return out[i].Height > out[j].Height
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// LocalHLSQuality 是传给 ffmpeg 的本地 HLS 画质参数。
|
||||
type LocalHLSQuality struct {
|
||||
ID string
|
||||
Label string
|
||||
Height int
|
||||
VideoBitrate string
|
||||
MaxRate string
|
||||
BufSize string
|
||||
}
|
||||
|
||||
var localHLSQualities = map[string]LocalHLSQuality{
|
||||
"source": {ID: "source", Label: "原画", Height: 0, VideoBitrate: "8000k", MaxRate: "10000k", BufSize: "12000k"},
|
||||
"1080": {ID: "1080", Label: "1080P", Height: 1080, VideoBitrate: "4000k", MaxRate: "4500k", BufSize: "6000k"},
|
||||
"720": {ID: "720", Label: "720P", Height: 720, VideoBitrate: "2500k", MaxRate: "2800k", BufSize: "4000k"},
|
||||
"480": {ID: "480", Label: "480P", Height: 480, VideoBitrate: "1200k", MaxRate: "1400k", BufSize: "2000k"},
|
||||
}
|
||||
|
||||
func LocalHLSQualityByID(id string) (LocalHLSQuality, bool) {
|
||||
quality, ok := localHLSQualities[strings.TrimSpace(id)]
|
||||
return quality, ok
|
||||
}
|
||||
|
||||
func DefaultLocalHLSQualityID(m *model.Media) string {
|
||||
sourceHeight := mediaSourceHeight(m)
|
||||
switch {
|
||||
case sourceHeight >= 1080:
|
||||
return "1080"
|
||||
case sourceHeight >= 720:
|
||||
return "720"
|
||||
case sourceHeight >= 480:
|
||||
return "480"
|
||||
case sourceHeight > 0:
|
||||
return "source"
|
||||
default:
|
||||
return "720"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||
)
|
||||
|
||||
func TestCloud115QualityOptionsDefault1080AndTranscode(t *testing.T) {
|
||||
media := &model.Media{Height: 1080}
|
||||
data := &cloud115.VideoPlayData{
|
||||
DefinitionListNew: map[string]string{"3": "超清"},
|
||||
VideoURL: []cloud115.VideoURLItem{
|
||||
{Definition: 3, DefinitionN: 3, Height: 720, Width: 1280, Title: "超清"},
|
||||
},
|
||||
}
|
||||
options := Cloud115QualityOptions(media, data)
|
||||
if got := DefaultCloud115Quality(options); got != "4" {
|
||||
t.Fatalf("default quality = %q, want 4", got)
|
||||
}
|
||||
quality4, ok := findPlaybackQuality(options, "4")
|
||||
if !ok {
|
||||
t.Fatal("1080P option missing")
|
||||
}
|
||||
if quality4.Available || !quality4.RequiresTranscode {
|
||||
t.Fatalf("1080P should require transcode: %#v", quality4)
|
||||
}
|
||||
quality3, ok := findPlaybackQuality(options, "3")
|
||||
if !ok || !quality3.Available || quality3.RequiresTranscode {
|
||||
t.Fatalf("超清 should be available: %#v", quality3)
|
||||
}
|
||||
if quality4K, ok := findPlaybackQuality(options, "5"); ok {
|
||||
t.Fatalf("1080P source should not list 4K: %#v", quality4K)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloud115QualityOptionsListsAvailableOnlyOnce(t *testing.T) {
|
||||
media := &model.Media{Height: 2160}
|
||||
data := &cloud115.VideoPlayData{
|
||||
DefinitionListNew: map[string]string{"4": "1080P", "5": "4K"},
|
||||
VideoURL: []cloud115.VideoURLItem{
|
||||
{Definition: 4, DefinitionN: 4, Height: 1080},
|
||||
{Definition: 5, DefinitionN: 5, Height: 2160},
|
||||
},
|
||||
}
|
||||
options := Cloud115QualityOptions(media, data)
|
||||
for _, id := range []string{"4", "5"} {
|
||||
quality, ok := findPlaybackQuality(options, id)
|
||||
if !ok || !quality.Available {
|
||||
t.Fatalf("quality %s should be available: %#v", id, quality)
|
||||
}
|
||||
}
|
||||
quality5, _ := findPlaybackQuality(options, "5")
|
||||
if !quality5.RequiresVIP {
|
||||
t.Fatalf("4K should be marked VIP: %#v", quality5)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalQualityOptionsFollowSourceHeight(t *testing.T) {
|
||||
media := &model.Media{Height: 720}
|
||||
options := LocalQualityOptions(media)
|
||||
if _, ok := findPlaybackQuality(options, "1080"); ok {
|
||||
t.Fatal("720p source should not list 1080P")
|
||||
}
|
||||
for _, id := range []string{"source", "720", "480"} {
|
||||
if _, ok := findPlaybackQuality(options, id); !ok {
|
||||
t.Fatalf("local quality %s missing", id)
|
||||
}
|
||||
}
|
||||
if got := DefaultLocalHLSQualityID(media); got != "720" {
|
||||
t.Fatalf("default local quality = %q, want 720", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalHLSQualityFFmpegArgs(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Transcoder.MaxHeight = 720
|
||||
cfg.Transcoder.SegmentSeconds = 4
|
||||
cfg.Transcoder.Realtime = false
|
||||
cfg.Transcoder.HardwareAccel = false
|
||||
|
||||
quality480, _ := LocalHLSQualityByID("480")
|
||||
args := buildFFmpegArgsForInput(cfg, transcodeInput{Source: "/x.mkv", Quality: &quality480}, "/o/x.m3u8", "/o/seg_%05d.ts")
|
||||
joined := strings.Join(args, " ")
|
||||
if !strings.Contains(joined, "scale=-2:min(480\\,ih)") {
|
||||
t.Fatalf("480P scale filter missing: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "-b:v 1200k") {
|
||||
t.Fatalf("480P bitrate missing: %s", joined)
|
||||
}
|
||||
|
||||
cfg.Transcoder.HardwareAccel = true
|
||||
cfg.Transcoder.Encoder = "nvenc"
|
||||
qualitySource, _ := LocalHLSQualityByID("source")
|
||||
args = buildFFmpegArgsForInput(cfg, transcodeInput{Source: "/x.mkv", Quality: &qualitySource}, "/o/x.m3u8", "/o/seg_%05d.ts")
|
||||
joined = strings.Join(args, " ")
|
||||
if strings.Contains(joined, "h264_nvenc") {
|
||||
t.Fatalf("source quality should not use hardware scale path: %s", joined)
|
||||
}
|
||||
if strings.Contains(joined, "scale=-2:min(") {
|
||||
t.Fatalf("source quality should not downscale: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "libx264") {
|
||||
t.Fatalf("source quality should use software x264: %s", joined)
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,7 @@ type Container struct {
|
||||
RecognitionWords *RecognitionWordsService
|
||||
Danmaku *DanmakuService
|
||||
Strm *StrmService
|
||||
Cloud115 *Cloud115PlaybackService
|
||||
Database *DatabaseAdminService
|
||||
FFTools *FFmpegToolsService
|
||||
|
||||
|
||||
@@ -28,13 +28,13 @@ func newServiceContainer(cfg *config.Config, log *zap.Logger, repos *repository.
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
repos: repos,
|
||||
version: normalizeSystemUpdateVersion(version),
|
||||
c: &Container{
|
||||
Version: normalizeSystemUpdateVersion(version),
|
||||
Cfg: cfg,
|
||||
Log: log,
|
||||
Repo: repos,
|
||||
},
|
||||
version: normalizeSystemUpdateVersion(version),
|
||||
c: &Container{
|
||||
Version: normalizeSystemUpdateVersion(version),
|
||||
Cfg: cfg,
|
||||
Log: log,
|
||||
Repo: repos,
|
||||
},
|
||||
}
|
||||
builder.startRealtimeServices()
|
||||
builder.initProviderServices()
|
||||
@@ -119,6 +119,7 @@ func (b *serviceContainerBuilder) initContentServices() {
|
||||
b.c.Profile = NewProfileService(b.log, b.repos)
|
||||
b.c.Audit = NewAuditService(b.log, b.repos)
|
||||
b.c.Strm = NewStrmService(b.cfg, b.log, b.repos, b.c.Crypto)
|
||||
b.c.Cloud115 = NewCloud115PlaybackService(b.cfg, b.log, b.repos, b.c.Strm)
|
||||
// ffmpeg/ffprobe 一键下载安装(data/tools/ffmpeg/)。
|
||||
b.c.FFTools = NewFFmpegToolsService(b.cfg, b.log, b.repos)
|
||||
// 弹幕 hash 识别需要把 strm 指向解析成可拉取的直链/本地路径。
|
||||
|
||||
@@ -26,7 +26,8 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request,
|
||||
startSec := parseHLSStartSec(r)
|
||||
seekGen := parseHLSSeekGen(r)
|
||||
subtitleStream := parseHLSSubtitleStream(r)
|
||||
if _, err := s.transcoder.EnsureJobFromSubtitle(r.Context(), mediaID, startSec, seekGen, subtitleStream); err != nil {
|
||||
quality := parseHLSQuality(r)
|
||||
if _, err := s.transcoder.EnsureJobFromSubtitleQuality(r.Context(), mediaID, startSec, seekGen, subtitleStream, quality); err != nil {
|
||||
return err
|
||||
}
|
||||
s.transcoder.TouchJob(mediaID)
|
||||
@@ -76,6 +77,20 @@ func parseHLSSubtitleStream(r *http.Request) int {
|
||||
return v
|
||||
}
|
||||
|
||||
func parseHLSQuality(r *http.Request) string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
quality := strings.TrimSpace(r.URL.Query().Get("quality"))
|
||||
if quality == "" {
|
||||
return ""
|
||||
}
|
||||
if _, ok := LocalHLSQualityByID(quality); !ok {
|
||||
return ""
|
||||
}
|
||||
return quality
|
||||
}
|
||||
|
||||
func parseHLSStartSec(r *http.Request) float64 {
|
||||
if r == nil {
|
||||
return 0
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -47,8 +47,8 @@ type TranscoderService struct {
|
||||
repo *repository.Container
|
||||
hub *Hub
|
||||
|
||||
mu sync.Mutex
|
||||
jobs map[string]*hlsJob
|
||||
mu sync.Mutex
|
||||
jobs map[string]*hlsJob
|
||||
// startGates serializes EnsureJobFrom / StopJob per media so concurrent
|
||||
// playlist hits cannot spawn multiple ffmpeg writers into one HLS dir.
|
||||
startGates sync.Map // mediaID -> *sync.Mutex
|
||||
@@ -74,6 +74,8 @@ type hlsJob struct {
|
||||
// subtitleStream is an absolute ffmpeg stream index to burn into the
|
||||
// picture, or -1 when no bitmap subtitle is selected.
|
||||
subtitleStream int
|
||||
// quality is the requested local HLS quality profile ("" = global config).
|
||||
quality string
|
||||
// done is closed when the ffmpeg goroutine fully exits (after process death).
|
||||
done chan struct{}
|
||||
}
|
||||
@@ -130,12 +132,27 @@ func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, s
|
||||
// EnsureJobFromSubtitle is EnsureJobFrom with an optional bitmap subtitle
|
||||
// stream. Selecting or closing a burned subtitle creates a new HLS generation.
|
||||
func (t *TranscoderService) EnsureJobFromSubtitle(ctx context.Context, mediaID string, startSec float64, seekGen int64, subtitleStream int) (string, error) {
|
||||
return t.EnsureJobFromSubtitleQuality(ctx, mediaID, startSec, seekGen, subtitleStream, "")
|
||||
}
|
||||
|
||||
// EnsureJobFromSubtitleQuality is EnsureJobFromSubtitle with an explicit local
|
||||
// HLS quality profile. Empty qualityID keeps the global transcoder config.
|
||||
func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, mediaID string, startSec float64, seekGen int64, subtitleStream int, qualityID string) (string, error) {
|
||||
if !t.cfg.Transcoder.Enabled {
|
||||
return "", ErrTranscodeDisabled
|
||||
}
|
||||
if startSec < 0 {
|
||||
startSec = 0
|
||||
}
|
||||
var quality *LocalHLSQuality
|
||||
if requested := strings.TrimSpace(qualityID); requested != "" {
|
||||
profile, ok := LocalHLSQualityByID(requested)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("未知的 HLS 画质:%s", requested)
|
||||
}
|
||||
quality = &profile
|
||||
qualityID = profile.ID
|
||||
}
|
||||
m, err := t.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -150,12 +167,12 @@ func (t *TranscoderService) EnsureJobFromSubtitle(ctx context.Context, mediaID s
|
||||
|
||||
t.mu.Lock()
|
||||
if existing, ok := t.jobs[mediaID]; ok {
|
||||
if sameHLSConfiguration(existing, startSec, subtitleStream) {
|
||||
if sameHLSConfiguration(existing, startSec, subtitleStream, qualityID) {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
}
|
||||
if !shouldReplaceHLSJobConfiguration(existing, startSec, seekGen, subtitleStream) {
|
||||
if !shouldReplaceHLSJobConfigurationQuality(existing, startSec, seekGen, subtitleStream, qualityID) {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
@@ -175,6 +192,7 @@ func (t *TranscoderService) EnsureJobFromSubtitle(ctx context.Context, mediaID s
|
||||
if subtitleStream >= 0 {
|
||||
input.SubtitleStream = &subtitleStream
|
||||
}
|
||||
input.Quality = quality
|
||||
t.maybeFillDuration(ctx, m, input)
|
||||
if _, err := t.resolveFFmpegPath(); err != nil {
|
||||
return "", err
|
||||
@@ -189,12 +207,12 @@ func (t *TranscoderService) EnsureJobFromSubtitle(ctx context.Context, mediaID s
|
||||
|
||||
t.mu.Lock()
|
||||
if existing, ok := t.jobs[mediaID]; ok {
|
||||
if sameHLSConfiguration(existing, startSec, subtitleStream) {
|
||||
if sameHLSConfiguration(existing, startSec, subtitleStream, qualityID) {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
}
|
||||
if !shouldReplaceHLSJobConfiguration(existing, startSec, seekGen, subtitleStream) {
|
||||
if !shouldReplaceHLSJobConfigurationQuality(existing, startSec, seekGen, subtitleStream, qualityID) {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
@@ -224,6 +242,7 @@ func (t *TranscoderService) EnsureJobFromSubtitle(ctx context.Context, mediaID s
|
||||
startSec: startSec,
|
||||
seekGen: seekGen,
|
||||
subtitleStream: subtitleStream,
|
||||
quality: qualityID,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
t.jobs[mediaID] = job
|
||||
@@ -250,10 +269,14 @@ func shouldReplaceHLSJob(existing *hlsJob, startSec float64, seekGen int64) bool
|
||||
}
|
||||
|
||||
func shouldReplaceHLSJobConfiguration(existing *hlsJob, startSec float64, seekGen int64, subtitleStream int) bool {
|
||||
return shouldReplaceHLSJobConfigurationQuality(existing, startSec, seekGen, subtitleStream, "")
|
||||
}
|
||||
|
||||
func shouldReplaceHLSJobConfigurationQuality(existing *hlsJob, startSec float64, seekGen int64, subtitleStream int, quality string) bool {
|
||||
if existing == nil {
|
||||
return true
|
||||
}
|
||||
if sameHLSConfiguration(existing, startSec, subtitleStream) {
|
||||
if sameHLSConfiguration(existing, startSec, subtitleStream, quality) {
|
||||
return false
|
||||
}
|
||||
return newerHLSGenerationMayReplace(existing, seekGen)
|
||||
@@ -270,9 +293,10 @@ func newerHLSGenerationMayReplace(existing *hlsJob, seekGen int64) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func sameHLSConfiguration(existing *hlsJob, startSec float64, subtitleStream int) bool {
|
||||
func sameHLSConfiguration(existing *hlsJob, startSec float64, subtitleStream int, quality string) bool {
|
||||
return existing != nil &&
|
||||
existing.subtitleStream == subtitleStream &&
|
||||
existing.quality == quality &&
|
||||
sameHLSStart(existing.startSec, startSec)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ type transcodeInput struct {
|
||||
Headers map[string]string
|
||||
StartSec float64
|
||||
SubtitleStream *int
|
||||
Quality *LocalHLSQuality
|
||||
}
|
||||
|
||||
type ffmpegArgSettings struct {
|
||||
@@ -28,6 +29,7 @@ type ffmpegArgSettings struct {
|
||||
realtime bool
|
||||
threads int
|
||||
vaapiDevice string
|
||||
noScale bool
|
||||
}
|
||||
|
||||
type ffmpegVideoPlan struct {
|
||||
@@ -45,7 +47,11 @@ func buildFFmpegArgs(cfg *config.Config, source, playlist, segments string) []st
|
||||
}
|
||||
|
||||
func buildFFmpegArgsForInput(cfg *config.Config, input transcodeInput, playlist, segments string) []string {
|
||||
settings := ffmpegArgSettingsFromConfig(cfg)
|
||||
settings := ffmpegArgSettingsFromConfig(cfg, input.Quality)
|
||||
if settings.noScale {
|
||||
// 原画档位不做硬件缩放的兼容处理,直接走软件编码,保持源分辨率。
|
||||
settings.encoder = ""
|
||||
}
|
||||
video := ffmpegVideoPlanForSettings(settings)
|
||||
if input.SubtitleStream != nil {
|
||||
// Bitmap subtitles must be composited in software. Keeping CUDA/QSV/
|
||||
@@ -64,7 +70,7 @@ func buildFFmpegArgsForInput(cfg *config.Config, input transcodeInput, playlist,
|
||||
return args
|
||||
}
|
||||
|
||||
func ffmpegArgSettingsFromConfig(cfg *config.Config) ffmpegArgSettings {
|
||||
func ffmpegArgSettingsFromConfig(cfg *config.Config, quality *LocalHLSQuality) ffmpegArgSettings {
|
||||
settings := ffmpegArgSettings{
|
||||
bitrate: ffmpegDefaultString(cfg.Transcoder.VideoBitrate, "1500k"),
|
||||
maxrate: ffmpegDefaultString(cfg.Transcoder.MaxRate, "1800k"),
|
||||
@@ -79,7 +85,23 @@ func ffmpegArgSettingsFromConfig(cfg *config.Config) ffmpegArgSettings {
|
||||
if cfg.Transcoder.HardwareAccel {
|
||||
settings.encoder = normalizedHardwareEncoder(cfg.Transcoder.Encoder)
|
||||
}
|
||||
if settings.height <= 0 {
|
||||
if quality != nil {
|
||||
if strings.TrimSpace(quality.VideoBitrate) != "" {
|
||||
settings.bitrate = strings.TrimSpace(quality.VideoBitrate)
|
||||
}
|
||||
if strings.TrimSpace(quality.MaxRate) != "" {
|
||||
settings.maxrate = strings.TrimSpace(quality.MaxRate)
|
||||
}
|
||||
if strings.TrimSpace(quality.BufSize) != "" {
|
||||
settings.bufsize = strings.TrimSpace(quality.BufSize)
|
||||
}
|
||||
if quality.Height > 0 {
|
||||
settings.height = quality.Height
|
||||
} else {
|
||||
settings.noScale = true
|
||||
}
|
||||
}
|
||||
if settings.height <= 0 && !settings.noScale {
|
||||
settings.height = 720
|
||||
}
|
||||
if settings.segmentSeconds <= 0 {
|
||||
@@ -118,8 +140,12 @@ func ffmpegVideoPlanForSettings(settings ffmpegArgSettings) ffmpegVideoPlan {
|
||||
codec: "h264_vaapi",
|
||||
}
|
||||
default:
|
||||
filter := ""
|
||||
if !settings.noScale {
|
||||
filter = fmt.Sprintf("scale=-2:min(%d\\,ih)", settings.height)
|
||||
}
|
||||
return ffmpegVideoPlan{
|
||||
filter: fmt.Sprintf("scale=-2:min(%d\\,ih)", settings.height),
|
||||
filter: filter,
|
||||
codec: "libx264",
|
||||
preset: settings.preset,
|
||||
}
|
||||
@@ -149,14 +175,18 @@ func appendInputAndVideoArgs(args []string, input transcodeInput, settings ffmpe
|
||||
}
|
||||
args = append(args, "-i", input.Source)
|
||||
if input.SubtitleStream != nil {
|
||||
filter := fmt.Sprintf(
|
||||
"[0:v:0][0:%d]overlay=0:0:eof_action=pass,scale=-2:min(%d\\,ih)[v]",
|
||||
*input.SubtitleStream,
|
||||
settings.height,
|
||||
)
|
||||
filter := fmt.Sprintf("[0:v:0][0:%d]overlay=0:0:eof_action=pass", *input.SubtitleStream)
|
||||
if !settings.noScale {
|
||||
filter += fmt.Sprintf(",scale=-2:min(%d\\,ih)", settings.height)
|
||||
}
|
||||
filter += "[v]"
|
||||
args = append(args, "-filter_complex", filter, "-map", "[v]", "-map", "0:a:0?", "-c:v", video.codec)
|
||||
} else {
|
||||
args = append(args, "-map", "0:v:0?", "-map", "0:a:0?", "-vf", video.filter, "-c:v", video.codec)
|
||||
args = append(args, "-map", "0:v:0?", "-map", "0:a:0?")
|
||||
if video.filter != "" {
|
||||
args = append(args, "-vf", video.filter)
|
||||
}
|
||||
args = append(args, "-c:v", video.codec)
|
||||
}
|
||||
if settings.threads > 0 && video.codec == "libx264" {
|
||||
args = append(args, "-threads", strconv.Itoa(settings.threads))
|
||||
|
||||
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
|
||||
}
|
||||
+12
-2
@@ -147,7 +147,7 @@ export function streamURL(mediaId: string): string {
|
||||
|
||||
// hlsURL returns the m3u8 playlist URL fed into hls.js.
|
||||
// startSec > 0 asks the server to (re)start ffmpeg from that source offset.
|
||||
export function hlsURL(mediaId: string, startSec = 0, subtitleStream?: number): string {
|
||||
export function hlsURL(mediaId: string, startSec = 0, subtitleStream?: number, quality?: string): string {
|
||||
const safeStart = Math.max(0, Math.round(startSec * 1000) / 1000)
|
||||
// Always send start= (including 0) so the server can tell an intentional
|
||||
// restart-from-head apart from a missing query on a stale refresh.
|
||||
@@ -159,7 +159,17 @@ export function hlsURL(mediaId: string, startSec = 0, subtitleStream?: number):
|
||||
subtitleStream !== undefined && subtitleStream >= 0
|
||||
? `&subtitle=${encodeURIComponent(String(subtitleStream))}`
|
||||
: ''
|
||||
return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}${start}${subtitle}${bust}`
|
||||
const qualityQuery = quality ? `&quality=${encodeURIComponent(quality)}` : ''
|
||||
return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}${start}${subtitle}${qualityQuery}${bust}`
|
||||
}
|
||||
|
||||
// cloudHlsURL returns the MeBox-proxied 115 cloud HLS master playlist URL.
|
||||
// A browser cannot fetch the 115 CDN directly because its CORS policy only
|
||||
// allows https://115.com; the backend rewrites all child URLs to this
|
||||
// same-origin proxy.
|
||||
export function cloudHlsURL(mediaId: string, definition: string): string {
|
||||
const quality = definition ? `&definition=${encodeURIComponent(definition)}` : ''
|
||||
return `/api/cloud115/media/${encodeURIComponent(mediaId)}/master.m3u8?${tokenQuery()}${profileQuery()}${quality}&media_id=${encodeURIComponent(mediaId)}`
|
||||
}
|
||||
|
||||
// Stop an on-demand HLS job. keepalive makes the request survive page
|
||||
|
||||
+13
-1
@@ -1,5 +1,5 @@
|
||||
import { api, BATCH_REQUEST_TIMEOUT, LONG_REQUEST_TIMEOUT } from './client'
|
||||
import type { Library, LibraryRoot, Media, ScanResult } from '../types'
|
||||
import type { Library, LibraryRoot, Media, PlaybackInfo, ScanResult } from '../types'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
|
||||
export interface MediaPage {
|
||||
@@ -233,6 +233,18 @@ export const mediaAPI = {
|
||||
|
||||
get: (id: string) => api.get<Media>(`/media/${id}`).then((r) => r.data),
|
||||
|
||||
playbackInfo: (id: string, quality?: number) =>
|
||||
api
|
||||
.get<PlaybackInfo>(`/media/${id}/playback`, {
|
||||
params: quality && quality > 0 ? { quality } : undefined,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
startCloudTranscode: (id: string, definition: number) =>
|
||||
api
|
||||
.post<PlaybackInfo>(`/media/${id}/transcode`, { definition })
|
||||
.then((r) => r.data),
|
||||
|
||||
getEpisodes: (id: string) =>
|
||||
api.get<{ items: Media[]; total: number }>(`/media/${id}/episodes`).then((r) => r.data),
|
||||
|
||||
|
||||
@@ -3,8 +3,12 @@ import { createPortal } from 'react-dom'
|
||||
import {
|
||||
Captions,
|
||||
CaptionsOff,
|
||||
Check,
|
||||
FastForward,
|
||||
Gauge,
|
||||
ListVideo,
|
||||
Loader2,
|
||||
Lock,
|
||||
Maximize,
|
||||
MessageSquareText,
|
||||
Minimize,
|
||||
@@ -18,6 +22,7 @@ import {
|
||||
VolumeX,
|
||||
} from 'lucide-react'
|
||||
import type { SubtitleTrack } from '../api/subtitles'
|
||||
import type { PlaybackQuality } from '../types'
|
||||
import type { SubtitleChineseMode } from '../utils/subtitleChinese'
|
||||
import {
|
||||
SUBTITLE_POSITION_OPTIONS,
|
||||
@@ -86,6 +91,10 @@ type PlayerControlsProps = {
|
||||
playlistOpen?: boolean
|
||||
hasPlaylist?: boolean
|
||||
onTogglePlaylist?: () => void
|
||||
qualities?: PlaybackQuality[]
|
||||
selectedQuality?: string
|
||||
onSelectQuality?: (quality: PlaybackQuality) => void
|
||||
showQuality?: boolean
|
||||
/** Media metadata duration (seconds). Used when HLS only knows transcoded length. */
|
||||
knownDuration?: number
|
||||
/** Absolute source offset of the current HLS session (seconds). */
|
||||
@@ -122,6 +131,10 @@ export function PlayerControls({
|
||||
playlistOpen = false,
|
||||
hasPlaylist = false,
|
||||
onTogglePlaylist,
|
||||
qualities = [],
|
||||
selectedQuality = '',
|
||||
onSelectQuality,
|
||||
showQuality = false,
|
||||
knownDuration = 0,
|
||||
streamOffset = 0,
|
||||
onSeekAbsolute,
|
||||
@@ -144,10 +157,13 @@ export function PlayerControls({
|
||||
const [scrubValue, setScrubValue] = useState<number | null>(null)
|
||||
const [subtitleMenuOpen, setSubtitleMenuOpen] = useState(false)
|
||||
const subtitleMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
const [qualityMenuOpen, setQualityMenuOpen] = useState(false)
|
||||
const qualityMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const controlsHoveredRef = useRef(false)
|
||||
const isScrubbingRef = useRef(false)
|
||||
const subtitleMenuOpenRef = useRef(false)
|
||||
const qualityMenuOpenRef = useRef(false)
|
||||
const danmakuOpenRef = useRef(false)
|
||||
const playlistOpenRef = useRef(false)
|
||||
const pendingSeekRef = useRef<number | null>(null)
|
||||
@@ -172,6 +188,10 @@ export function PlayerControls({
|
||||
subtitleMenuOpenRef.current = subtitleMenuOpen
|
||||
}, [subtitleMenuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
qualityMenuOpenRef.current = qualityMenuOpen
|
||||
}, [qualityMenuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
danmakuOpenRef.current = danmakuOpen
|
||||
}, [danmakuOpen])
|
||||
@@ -199,17 +219,20 @@ export function PlayerControls({
|
||||
setMuted(next === 0)
|
||||
}, [video, volumeProp])
|
||||
|
||||
// 点击控制栏外部时关闭字幕菜单
|
||||
// 点击控制栏外部时关闭字幕/画质菜单
|
||||
useEffect(() => {
|
||||
if (!subtitleMenuOpen) return
|
||||
if (!subtitleMenuOpen && !qualityMenuOpen) return
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (subtitleMenuRef.current && !subtitleMenuRef.current.contains(e.target as Node)) {
|
||||
setSubtitleMenuOpen(false)
|
||||
}
|
||||
if (qualityMenuRef.current && !qualityMenuRef.current.contains(e.target as Node)) {
|
||||
setQualityMenuOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', onDocClick)
|
||||
return () => document.removeEventListener('mousedown', onDocClick)
|
||||
}, [subtitleMenuOpen])
|
||||
}, [subtitleMenuOpen, qualityMenuOpen])
|
||||
|
||||
// 播放时 3 秒无操作自动隐藏控制栏;暂停/悬停/拖动进度条/打开菜单时保持显示。
|
||||
// 监听挂在整个播放器舞台容器(data-player-stage)上,避免光标移到控制栏时因离开视频画面而误触发 mouseleave。
|
||||
@@ -225,6 +248,7 @@ export function PlayerControls({
|
||||
!controlsHoveredRef.current &&
|
||||
!isScrubbingRef.current &&
|
||||
!subtitleMenuOpenRef.current &&
|
||||
!qualityMenuOpenRef.current &&
|
||||
!danmakuOpenRef.current &&
|
||||
!playlistOpenRef.current
|
||||
) {
|
||||
@@ -233,6 +257,7 @@ export function PlayerControls({
|
||||
!controlsHoveredRef.current &&
|
||||
!isScrubbingRef.current &&
|
||||
!subtitleMenuOpenRef.current &&
|
||||
!qualityMenuOpenRef.current &&
|
||||
!danmakuOpenRef.current &&
|
||||
!playlistOpenRef.current
|
||||
) {
|
||||
@@ -335,7 +360,7 @@ export function PlayerControls({
|
||||
|
||||
// 当悬停或菜单状态改变时,更新控制栏计时器
|
||||
useEffect(() => {
|
||||
if (controlsHovered || isScrubbing || subtitleMenuOpen || danmakuOpen || playlistOpen) {
|
||||
if (controlsHovered || isScrubbing || subtitleMenuOpen || qualityMenuOpen || danmakuOpen || playlistOpen) {
|
||||
onUiVisibleChange(true)
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
} else {
|
||||
@@ -345,7 +370,7 @@ export function PlayerControls({
|
||||
hideTimerRef.current = setTimeout(() => onUiVisibleChange(false), 3000)
|
||||
}
|
||||
}
|
||||
}, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen, playlistOpen, onUiVisibleChange, video])
|
||||
}, [controlsHovered, isScrubbing, subtitleMenuOpen, qualityMenuOpen, danmakuOpen, playlistOpen, onUiVisibleChange, video])
|
||||
|
||||
const togglePlay = () => {
|
||||
const el = video()
|
||||
@@ -409,6 +434,7 @@ export function PlayerControls({
|
||||
controlsHoveredRef.current ||
|
||||
isScrubbingRef.current ||
|
||||
subtitleMenuOpenRef.current ||
|
||||
qualityMenuOpenRef.current ||
|
||||
danmakuOpenRef.current ||
|
||||
playlistOpenRef.current
|
||||
) {
|
||||
@@ -419,6 +445,7 @@ export function PlayerControls({
|
||||
!controlsHoveredRef.current &&
|
||||
!isScrubbingRef.current &&
|
||||
!subtitleMenuOpenRef.current &&
|
||||
!qualityMenuOpenRef.current &&
|
||||
!danmakuOpenRef.current &&
|
||||
!playlistOpenRef.current
|
||||
) {
|
||||
@@ -549,6 +576,11 @@ export function PlayerControls({
|
||||
(selectedSubtitle.delivery === 'webvtt' || selectedSubtitle.delivery === 'ass')
|
||||
const canAdjustSelectedSubtitle = selectedSubtitle?.delivery === 'webvtt'
|
||||
const usesOriginalASS = selectedSubtitle?.delivery === 'ass'
|
||||
const selectedQualityLabel = qualities.find((quality) => quality.id === selectedQuality)?.label ?? ''
|
||||
const qualityGroups = [
|
||||
{ key: 'cloud', label: '115 云端', items: qualities.filter((quality) => quality.source !== 'local') },
|
||||
{ key: 'local', label: '本地 HLS', items: qualities.filter((quality) => quality.source === 'local') },
|
||||
].filter((group) => group.items.length > 0)
|
||||
|
||||
const seekOverlay = seekHint && stageEl
|
||||
? createPortal(
|
||||
@@ -660,10 +692,75 @@ export function PlayerControls({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showQuality && qualities.length > 0 && onSelectQuality && (
|
||||
<div className="relative" ref={qualityMenuRef}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSubtitleMenuOpen(false)
|
||||
setQualityMenuOpen((v) => !v)
|
||||
}}
|
||||
className="flex items-center gap-1 rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title="画质"
|
||||
>
|
||||
<Gauge size={18} className={qualityMenuOpen ? 'text-rose-400' : 'text-white/80'} />
|
||||
<span className="hidden text-[10px] font-medium text-white/80 sm:inline">
|
||||
{selectedQualityLabel || '画质'}
|
||||
</span>
|
||||
</button>
|
||||
{qualityMenuOpen && (
|
||||
<div className="absolute bottom-11 right-0 z-30 min-w-48 overflow-hidden rounded-xl border border-white/15 bg-black/85 p-1 shadow-2xl backdrop-blur">
|
||||
{qualityGroups.map((group) => (
|
||||
<div key={group.key}>
|
||||
<p className="px-3 pb-1 pt-2 text-[10px] uppercase tracking-wide text-white/40">
|
||||
{group.label}
|
||||
</p>
|
||||
{group.items.map((quality) => {
|
||||
const current = quality.id === selectedQuality
|
||||
return (
|
||||
<button
|
||||
key={`${quality.source}-${quality.id}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectQuality(quality)
|
||||
setQualityMenuOpen(false)
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs transition ${
|
||||
current
|
||||
? 'text-rose-400 hover:bg-white/10'
|
||||
: quality.requires_vip
|
||||
? 'text-white/55 hover:bg-white/10'
|
||||
: 'text-white/85 hover:bg-white/10'
|
||||
}`}
|
||||
title={quality.note || quality.label}
|
||||
>
|
||||
<span className="truncate">{quality.label}</span>
|
||||
{quality.requires_transcode && (
|
||||
<span className="ml-auto flex items-center gap-1 rounded bg-white/10 px-1.5 py-0.5 text-[9px] text-amber-300">
|
||||
<Loader2 size={10} />
|
||||
转码
|
||||
</span>
|
||||
)}
|
||||
{quality.requires_vip && <Lock size={11} className="ml-auto text-amber-300" />}
|
||||
{current && !quality.requires_transcode && (
|
||||
<Check size={13} className="ml-auto text-rose-400" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subs.length > 0 && (
|
||||
<div className="relative" ref={subtitleMenuRef}>
|
||||
<button
|
||||
onClick={() => setSubtitleMenuOpen((v) => !v)}
|
||||
onClick={() => {
|
||||
setQualityMenuOpen(false)
|
||||
setSubtitleMenuOpen((v) => !v)
|
||||
}}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title="字幕"
|
||||
>
|
||||
@@ -852,4 +949,4 @@ export function PlayerControls({
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+297
-13
@@ -4,7 +4,7 @@ import type Hls from 'hls.js'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { mediaAPI, libraryAPI } from '../api/library'
|
||||
import { hlsURL, postPlaybackProgressKeepalive, stopHLSJob, streamURL } from '../api/client'
|
||||
import { cloudHlsURL, hlsURL, postPlaybackProgressKeepalive, stopHLSJob, streamURL } from '../api/client'
|
||||
import {
|
||||
danmakuAPI,
|
||||
type DanmakuAnime,
|
||||
@@ -17,7 +17,7 @@ import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
import { systemAPI } from '../api/system'
|
||||
import { profileAPI } from '../api/profile'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { Media } from '../types'
|
||||
import type { Media, PlaybackInfo, PlaybackQuality } from '../types'
|
||||
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
|
||||
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||
import {
|
||||
@@ -124,6 +124,16 @@ export function PlayerPage() {
|
||||
const [initialSeekDone, setInitialSeekDone] = useState(false)
|
||||
// HLS session source offset: playlist t=0 maps to this absolute second.
|
||||
const [hlsStartSec, setHlsStartSec] = useState(0)
|
||||
// 统一播放能力:115 提供 direct → cloud_hls → local_hls,其它源 direct → local_hls。
|
||||
const [playbackInfo, setPlaybackInfo] = useState<PlaybackInfo | null>(null)
|
||||
const [hlsSource, setHlsSource] = useState<'cloud' | 'local'>('local')
|
||||
const [selectedQuality, setSelectedQuality] = useState('')
|
||||
const [cloudWaiting, setCloudWaiting] = useState(false)
|
||||
const [cloudWaitMessage, setCloudWaitMessage] = useState('')
|
||||
const [cloudWaitStartedAt, setCloudWaitStartedAt] = useState(0)
|
||||
const cloudPollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const cloudRetryRef = useRef(5)
|
||||
const pendingSeekRef = useRef<number | null>(null)
|
||||
|
||||
// 弹幕控制:状态来自 /api/danmaku/config 初始值,用户在面板里实时调整。
|
||||
const [danmakuOpen, setDanmakuOpen] = useState(false)
|
||||
@@ -407,6 +417,17 @@ export function PlayerPage() {
|
||||
setMedia(null)
|
||||
setLoadError('')
|
||||
setHlsStartSec(0)
|
||||
setPlaybackInfo(null)
|
||||
setHlsSource('local')
|
||||
setSelectedQuality('')
|
||||
setCloudWaiting(false)
|
||||
setCloudWaitMessage('')
|
||||
setCloudWaitStartedAt(0)
|
||||
pendingSeekRef.current = null
|
||||
if (cloudPollTimerRef.current) {
|
||||
clearTimeout(cloudPollTimerRef.current)
|
||||
cloudPollTimerRef.current = null
|
||||
}
|
||||
setDanmakuEpisodeId(null)
|
||||
setDanmakuCandidates([])
|
||||
setDanmakuAlternatives([])
|
||||
@@ -478,6 +499,117 @@ export function PlayerPage() {
|
||||
// Depend on media.id (not the media object): refreshing duration after
|
||||
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
|
||||
const mediaId = media?.id
|
||||
const playbackProvider = playbackInfo?.provider
|
||||
|
||||
// 加载统一播放能力:115 云端清晰度 + 本地 HLS 清晰度。
|
||||
useEffect(() => {
|
||||
if (!mediaId) return
|
||||
let cancelled = false
|
||||
mediaAPI
|
||||
.playbackInfo(mediaId)
|
||||
.then((info) => {
|
||||
if (cancelled) return
|
||||
setPlaybackInfo(info)
|
||||
cloudRetryRef.current = Math.max(3, info.transcode.retry_after_sec || 5)
|
||||
setSelectedQuality((prev) => {
|
||||
if (prev && findPlaybackQualityById(info, prev)) return prev
|
||||
return info.default_quality || info.local_qualities?.[0]?.id || ''
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPlaybackInfo(null)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [mediaId])
|
||||
|
||||
useEffect(() => {
|
||||
cloudRetryRef.current = Math.max(3, playbackInfo?.transcode.retry_after_sec || 5)
|
||||
}, [playbackInfo])
|
||||
|
||||
const switchToLocalHLS = useCallback(
|
||||
(position = 0) => {
|
||||
setHlsSource('local')
|
||||
setCloudWaiting(false)
|
||||
setSelectedQuality((prev) => {
|
||||
if (playbackInfo?.local_qualities?.some((quality) => quality.id === prev)) return prev
|
||||
return defaultLocalQualityId(playbackInfo)
|
||||
})
|
||||
if (position > 2) setHlsStartSec(position)
|
||||
setPlaybackMode('hls')
|
||||
},
|
||||
[playbackInfo, setPlaybackMode],
|
||||
)
|
||||
|
||||
const startCloudTranscode = useCallback(
|
||||
async (definition: number) => {
|
||||
if (!mediaId) return
|
||||
try {
|
||||
const info = await mediaAPI.startCloudTranscode(mediaId, definition)
|
||||
setPlaybackInfo(info)
|
||||
if (info.transcode.state === 'ready') {
|
||||
setCloudWaiting(false)
|
||||
setCloudWaitMessage('')
|
||||
setHlsSource('cloud')
|
||||
setPlaybackMode('hls')
|
||||
return
|
||||
}
|
||||
setCloudWaiting(true)
|
||||
setCloudWaitMessage(info.transcode.message || '115 正在转码…')
|
||||
setCloudWaitStartedAt(Date.now())
|
||||
} catch {
|
||||
switchToLocalHLS()
|
||||
toast.error('115 云端转码触发失败,已切换本地转码')
|
||||
}
|
||||
},
|
||||
[mediaId, setPlaybackMode, switchToLocalHLS],
|
||||
)
|
||||
|
||||
// 云端转码等待:按后端建议间隔轮询,完成后自动切到云 HLS。
|
||||
useEffect(() => {
|
||||
if (!cloudWaiting || !mediaId) return
|
||||
let cancelled = false
|
||||
const poll = async () => {
|
||||
if (cancelled) return
|
||||
if (cloudWaitStartedAt > 0 && Date.now() - cloudWaitStartedAt > 10 * 60 * 1000) {
|
||||
switchToLocalHLS()
|
||||
toast.error('115 云端转码等待超时,已切换本地转码')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const definition = Number(selectedQuality) || undefined
|
||||
const info = await mediaAPI.playbackInfo(mediaId, definition)
|
||||
if (cancelled) return
|
||||
setPlaybackInfo(info)
|
||||
cloudRetryRef.current = Math.max(3, info.transcode.retry_after_sec || 5)
|
||||
if (info.transcode.state === 'ready') {
|
||||
setCloudWaiting(false)
|
||||
setCloudWaitMessage('')
|
||||
setHlsSource('cloud')
|
||||
setPlaybackMode('hls')
|
||||
return
|
||||
}
|
||||
if (info.transcode.state === 'unavailable') {
|
||||
switchToLocalHLS()
|
||||
toast.error(info.transcode.message || '115 云端转码不可用,已切换本地转码')
|
||||
return
|
||||
}
|
||||
setCloudWaitMessage(info.transcode.message || '115 正在转码…')
|
||||
cloudPollTimerRef.current = setTimeout(poll, cloudRetryRef.current * 1000)
|
||||
} catch {
|
||||
cloudPollTimerRef.current = setTimeout(poll, 5000)
|
||||
}
|
||||
}
|
||||
cloudPollTimerRef.current = setTimeout(poll, cloudRetryRef.current * 1000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (cloudPollTimerRef.current) {
|
||||
clearTimeout(cloudPollTimerRef.current)
|
||||
cloudPollTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [cloudWaiting, cloudWaitStartedAt, mediaId, selectedQuality, setPlaybackMode, switchToLocalHLS])
|
||||
|
||||
// 直连播放只发现外挂字幕;只有 HLS 模式需要探测可烧录的内嵌字幕。
|
||||
useEffect(() => {
|
||||
@@ -524,7 +656,14 @@ export function PlayerPage() {
|
||||
const video = ref.current
|
||||
const durationSec = currentMedia.duration_sec || 0
|
||||
if (mode === 'hls') {
|
||||
const url = hlsURL(mediaId, hlsStartSec, activeBurnedSubtitleStream)
|
||||
if (hlsSource === 'cloud' && cloudWaiting) {
|
||||
teardownHls()
|
||||
return
|
||||
}
|
||||
const url =
|
||||
hlsSource === 'cloud'
|
||||
? cloudHlsURL(mediaId, selectedQuality)
|
||||
: hlsURL(mediaId, hlsStartSec, activeBurnedSubtitleStream, selectedQuality)
|
||||
void import('hls.js').then(({ default: HlsCtor }) => {
|
||||
if (cancelled || !ref.current) return
|
||||
if (HlsCtor.isSupported()) {
|
||||
@@ -545,6 +684,15 @@ export function PlayerPage() {
|
||||
hls.loadSource(url)
|
||||
hls.attachMedia(video)
|
||||
hls.on(HlsCtor.Events.MANIFEST_PARSED, () => {
|
||||
if (pendingSeekRef.current !== null) {
|
||||
const target = pendingSeekRef.current
|
||||
pendingSeekRef.current = null
|
||||
try {
|
||||
video.currentTime = target
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
void video.play().catch(() => undefined)
|
||||
// .strm 入库时常缺 duration;转码启动时会补探测,这里刷新一次给进度条总时长。
|
||||
if (durationSec > 0) return
|
||||
@@ -558,7 +706,19 @@ export function PlayerPage() {
|
||||
})
|
||||
hls.on(HlsCtor.Events.ERROR, (_, data) => {
|
||||
if (data.fatal) {
|
||||
if (hlsSource === 'cloud') {
|
||||
const position = ref.current?.currentTime || 0
|
||||
setHlsUnavailable(false)
|
||||
switchToLocalHLS(position)
|
||||
toast.error('115 云端播放失败,切换本地转码')
|
||||
return
|
||||
}
|
||||
setHlsUnavailable(true)
|
||||
if (playbackProvider === 'cloud115') {
|
||||
setPlayerError('115 云端和本地转码均不可用,请检查账号授权或稍后重试。')
|
||||
toast.error('115 云端和本地转码均不可用')
|
||||
return
|
||||
}
|
||||
setPlayerError('HLS 转码不可用,正在尝试直接播放原始文件。若出现有画面无声音,通常是 MKV/AC3/EAC3 音轨需要配置本机 ffmpeg 转码为 AAC。')
|
||||
toast.error('HLS 转码失败,尝试切换到直接播放')
|
||||
setPlaybackMode('direct')
|
||||
@@ -614,31 +774,36 @@ export function PlayerPage() {
|
||||
}, [
|
||||
activeBurnedSubtitleStream,
|
||||
clearFallbackTimer,
|
||||
cloudWaiting,
|
||||
hlsSource,
|
||||
hlsUnavailable,
|
||||
hlsStartSec,
|
||||
mediaId,
|
||||
mode,
|
||||
playbackProvider,
|
||||
selectedQuality,
|
||||
setPlaybackMode,
|
||||
switchToLocalHLS,
|
||||
teardownHls,
|
||||
])
|
||||
|
||||
// Stop host ffmpeg when leaving this HLS player. The keepalive request also
|
||||
// survives route navigation while the component is being torn down.
|
||||
useEffect(() => {
|
||||
if (!mediaId || mode !== 'hls') return
|
||||
if (!mediaId || mode !== 'hls' || hlsSource !== 'local') return
|
||||
return () => {
|
||||
stopHLSJob(mediaId)
|
||||
}
|
||||
}, [mediaId, mode])
|
||||
}, [hlsSource, mediaId, mode])
|
||||
|
||||
// React cleanup is not guaranteed when a tab/window closes. pagehide fires
|
||||
// while the document can still dispatch a keepalive request.
|
||||
useEffect(() => {
|
||||
if (!mediaId || mode !== 'hls') return
|
||||
if (!mediaId || mode !== 'hls' || hlsSource !== 'local') return
|
||||
const stopOnPageExit = () => stopHLSJob(mediaId)
|
||||
window.addEventListener('pagehide', stopOnPageExit)
|
||||
return () => window.removeEventListener('pagehide', stopOnPageExit)
|
||||
}, [mediaId, mode])
|
||||
}, [hlsSource, mediaId, mode])
|
||||
|
||||
// 自动拉取已有的播放进度并恢复播放位置
|
||||
useEffect(() => {
|
||||
@@ -944,6 +1109,11 @@ export function PlayerPage() {
|
||||
}, [goBack, prevEpisode, nextEpisode, handlePrevEpisode, handleNextEpisode, playlistOpen, danmakuOpen])
|
||||
|
||||
const isDirectStream = isDirectStreamMedia(media)
|
||||
const qualityOptions =
|
||||
playbackInfo?.provider === 'cloud115'
|
||||
? [...(playbackInfo.cloud_qualities ?? []), ...(playbackInfo.local_qualities ?? [])]
|
||||
: (playbackInfo?.local_qualities ?? [])
|
||||
const showQuality = mode !== 'direct' && qualityOptions.length > 0
|
||||
|
||||
// 没有外挂字幕且第一条内嵌字幕是图片时,默认轨需要通过 HLS 烧录。
|
||||
useEffect(() => {
|
||||
@@ -975,9 +1145,36 @@ export function PlayerPage() {
|
||||
const next = mode === 'hls' ? 'direct' : 'hls'
|
||||
if (next === 'hls') {
|
||||
setHlsStartSec(0)
|
||||
const preferred =
|
||||
playbackInfo?.provider === 'cloud115'
|
||||
? findPlaybackQualityById(playbackInfo, selectedQuality) ??
|
||||
findPlaybackQualityById(playbackInfo, playbackInfo.default_quality)
|
||||
: undefined
|
||||
if (preferred?.source === 'cloud') {
|
||||
setHlsSource('cloud')
|
||||
if (preferred.available) {
|
||||
setCloudWaiting(false)
|
||||
} else {
|
||||
setCloudWaiting(true)
|
||||
setCloudWaitMessage(preferred.note || `正在等待 115 转码 ${preferred.label}…`)
|
||||
void startCloudTranscode(Number(preferred.id) || 4)
|
||||
}
|
||||
} else {
|
||||
switchToLocalHLS()
|
||||
}
|
||||
} else {
|
||||
setCloudWaiting(false)
|
||||
}
|
||||
setPlaybackMode(next)
|
||||
}, [isDirectStream, mode, setPlaybackMode])
|
||||
}, [
|
||||
isDirectStream,
|
||||
mode,
|
||||
playbackInfo,
|
||||
selectedQuality,
|
||||
setPlaybackMode,
|
||||
startCloudTranscode,
|
||||
switchToLocalHLS,
|
||||
])
|
||||
|
||||
const handleSeekAbsolute = useCallback(
|
||||
(absoluteSec: number) => {
|
||||
@@ -1019,6 +1216,10 @@ export function PlayerPage() {
|
||||
toast.error('图片字幕需要开启 HLS 转码后才能显示')
|
||||
return
|
||||
}
|
||||
if (nextTrack?.delivery === 'burn') {
|
||||
// PGS 等图片字幕必须走本地 FFmpeg 烧录,不能交给云端 HLS。
|
||||
switchToLocalHLS(ref.current?.currentTime || 0)
|
||||
}
|
||||
const burnChanged =
|
||||
oldTrack?.delivery === 'burn' || nextTrack?.delivery === 'burn'
|
||||
if (burnChanged && mode === 'hls' && ref.current) {
|
||||
@@ -1029,7 +1230,42 @@ export function PlayerPage() {
|
||||
setHlsStartSec(ref.current?.currentTime || 0)
|
||||
setPlaybackMode('hls')
|
||||
}
|
||||
}, [directOnly, hlsStartSec, isDirectStream, mode, setPlaybackMode, subs, subtitleIndex])
|
||||
}, [directOnly, hlsStartSec, isDirectStream, mode, setPlaybackMode, subs, subtitleIndex, switchToLocalHLS])
|
||||
|
||||
const selectPlaybackQuality = useCallback(
|
||||
(quality: PlaybackQuality) => {
|
||||
const video = ref.current
|
||||
const position = video?.currentTime || 0
|
||||
if (quality.source === 'original') {
|
||||
setCloudWaiting(false)
|
||||
setHlsSource('local')
|
||||
setPlaybackMode('direct')
|
||||
return
|
||||
}
|
||||
if (quality.source === 'cloud') {
|
||||
setSelectedQuality(quality.id)
|
||||
setHlsSource('cloud')
|
||||
if (quality.available) {
|
||||
setCloudWaiting(false)
|
||||
setCloudWaitMessage('')
|
||||
if (position > 2) pendingSeekRef.current = position
|
||||
setPlaybackMode('hls')
|
||||
} else {
|
||||
setCloudWaiting(true)
|
||||
setCloudWaitMessage(quality.note || `正在等待 115 转码 ${quality.label}…`)
|
||||
void startCloudTranscode(Number(quality.id) || 4)
|
||||
}
|
||||
return
|
||||
}
|
||||
setSelectedQuality(quality.id)
|
||||
setHlsSource('local')
|
||||
setCloudWaiting(false)
|
||||
setCloudWaitMessage('')
|
||||
if (position > 2) setHlsStartSec(position)
|
||||
setPlaybackMode('hls')
|
||||
},
|
||||
[setPlaybackMode, startCloudTranscode],
|
||||
)
|
||||
|
||||
const changeSubtitleChineseMode = useCallback(
|
||||
(nextMode: SubtitleChineseMode) => {
|
||||
@@ -1097,18 +1333,45 @@ export function PlayerPage() {
|
||||
} else if (directOnly) {
|
||||
setPlayerError('直接播放失败。当前为「客户端直连解码」模式,宿主机不转码;请使用支持该编码/封装的播放器(如 Infuse / VLC / Emby 客户端)播放,或关闭直连解码模式。')
|
||||
toast.error('直接播放失败(客户端直连解码模式)')
|
||||
} else if (playbackInfo?.provider === 'cloud115') {
|
||||
const preferred =
|
||||
findPlaybackQualityById(playbackInfo, selectedQuality) ??
|
||||
findPlaybackQualityById(playbackInfo, playbackInfo.default_quality)
|
||||
if (preferred?.source === 'cloud' && preferred.available) {
|
||||
setCloudWaiting(false)
|
||||
setHlsSource('cloud')
|
||||
setPlaybackMode('hls')
|
||||
} else if (preferred?.source === 'cloud') {
|
||||
setHlsSource('cloud')
|
||||
setCloudWaiting(true)
|
||||
setCloudWaitMessage(preferred.note || `正在等待 115 转码 ${preferred.label}…`)
|
||||
void startCloudTranscode(Number(preferred.id) || 4)
|
||||
} else {
|
||||
switchToLocalHLS(current?.currentTime || 0)
|
||||
}
|
||||
} else if (hlsUnavailable) {
|
||||
setPlayerError('直接播放失败,且 HLS 转码不可用。请检查文件是否存在,或配置本机 ffmpeg 后使用 HLS 转码播放。')
|
||||
toast.error('直接播放失败,HLS 转码不可用')
|
||||
} else {
|
||||
toast.error('直接播放失败,切换到 HLS 转码')
|
||||
setPlaybackMode('hls')
|
||||
switchToLocalHLS(current?.currentTime || 0)
|
||||
}
|
||||
}
|
||||
|
||||
clearFallbackTimer()
|
||||
fallbackTimerRef.current = setTimeout(fallbackDirectPlay, 1500)
|
||||
}, [clearFallbackTimer, directOnly, hlsUnavailable, mediaId, mode, setPlaybackMode])
|
||||
}, [
|
||||
clearFallbackTimer,
|
||||
directOnly,
|
||||
hlsUnavailable,
|
||||
mediaId,
|
||||
mode,
|
||||
playbackInfo,
|
||||
selectedQuality,
|
||||
setPlaybackMode,
|
||||
startCloudTranscode,
|
||||
switchToLocalHLS,
|
||||
])
|
||||
|
||||
const changeSubtitlePosition = useCallback((nextPosition: SubtitlePosition) => {
|
||||
setSubtitlePosition(nextPosition)
|
||||
@@ -1183,8 +1446,14 @@ export function PlayerPage() {
|
||||
hasPlaylist={playlistEpisodes.length > 0}
|
||||
onTogglePlaylist={togglePlaylistOpen}
|
||||
knownDuration={media?.duration_sec || 0}
|
||||
streamOffset={mode === 'hls' ? hlsStartSec : 0}
|
||||
onSeekAbsolute={mode === 'hls' ? handleSeekAbsolute : undefined}
|
||||
streamOffset={mode === 'hls' && hlsSource === 'local' ? hlsStartSec : 0}
|
||||
onSeekAbsolute={mode === 'hls' && hlsSource === 'local' ? handleSeekAbsolute : undefined}
|
||||
qualities={qualityOptions}
|
||||
selectedQuality={selectedQuality}
|
||||
onSelectQuality={selectPlaybackQuality}
|
||||
showQuality={showQuality}
|
||||
waiting={cloudWaiting}
|
||||
waitingMessage={cloudWaitMessage}
|
||||
playlistPanel={
|
||||
<PlayerPlaylistPanel
|
||||
open={playlistOpen}
|
||||
@@ -1248,6 +1517,21 @@ function formatEpisodeDisplay(ep: Media, siblings: Media[]): string {
|
||||
return ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : mediaTitle || title || '未命名'
|
||||
}
|
||||
|
||||
function findPlaybackQualityById(info: PlaybackInfo | null, id: string): PlaybackQuality | undefined {
|
||||
if (!info || !id) return undefined
|
||||
return [...(info.cloud_qualities ?? []), ...(info.local_qualities ?? [])].find(
|
||||
(quality) => quality.id === id,
|
||||
)
|
||||
}
|
||||
|
||||
function defaultLocalQualityId(info: PlaybackInfo | null): string {
|
||||
const qualities = info?.local_qualities ?? []
|
||||
for (const id of ['1080', '720', '480', 'source']) {
|
||||
if (qualities.some((quality) => quality.id === id)) return id
|
||||
}
|
||||
return qualities[0]?.id ?? '720'
|
||||
}
|
||||
|
||||
function looksLikeSeriesTitle(ep: Media, title: string, siblings: Media[]): boolean {
|
||||
const normalized = normalizeEpisodeTitle(title)
|
||||
if (!normalized) return true
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { CSSProperties, PointerEvent, ReactNode, RefObject } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
|
||||
import type { Media } from '../types'
|
||||
import type { Media, PlaybackQuality } from '../types'
|
||||
import {
|
||||
loadSubtitleChineseConverter,
|
||||
type SubtitleChineseMode,
|
||||
@@ -178,6 +179,12 @@ type PlayerVideoStageProps = {
|
||||
knownDuration?: number
|
||||
streamOffset?: number
|
||||
onSeekAbsolute?: (seconds: number) => boolean
|
||||
qualities?: PlaybackQuality[]
|
||||
selectedQuality?: string
|
||||
onSelectQuality?: (quality: PlaybackQuality) => void
|
||||
showQuality?: boolean
|
||||
waiting?: boolean
|
||||
waitingMessage?: string
|
||||
}
|
||||
|
||||
export function PlayerVideoStage({
|
||||
@@ -224,6 +231,12 @@ export function PlayerVideoStage({
|
||||
knownDuration,
|
||||
streamOffset,
|
||||
onSeekAbsolute,
|
||||
qualities = [],
|
||||
selectedQuality = '',
|
||||
onSelectQuality,
|
||||
showQuality = false,
|
||||
waiting = false,
|
||||
waitingMessage = '',
|
||||
}: PlayerVideoStageProps) {
|
||||
const stageRef = useRef<HTMLDivElement>(null)
|
||||
const [videoRatio, setVideoRatio] = useState<number | null>(null)
|
||||
@@ -666,6 +679,10 @@ export function PlayerVideoStage({
|
||||
knownDuration={knownDuration}
|
||||
streamOffset={streamOffset}
|
||||
onSeekAbsolute={onSeekAbsolute}
|
||||
qualities={qualities}
|
||||
selectedQuality={selectedQuality}
|
||||
onSelectQuality={onSelectQuality}
|
||||
showQuality={showQuality}
|
||||
/>
|
||||
{danmakuPanel}
|
||||
{playlistPanel}
|
||||
@@ -680,6 +697,15 @@ export function PlayerVideoStage({
|
||||
{playerError}
|
||||
</div>
|
||||
) : null}
|
||||
{waiting ? (
|
||||
<div className="absolute inset-0 z-40 flex items-center justify-center bg-black/60 px-4 backdrop-blur-sm">
|
||||
<div className="max-w-md rounded-xl border border-white/15 bg-black/85 px-6 py-5 text-center text-white shadow-2xl">
|
||||
<Loader2 className="mx-auto mb-3 animate-spin text-rose-400" size={28} />
|
||||
<p className="text-sm font-medium">{waitingMessage || '115 正在转码…'}</p>
|
||||
<p className="mt-1 text-xs text-white/60">转码完成后会自动播放</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,4 +7,5 @@ export * from './history'
|
||||
export * from './library'
|
||||
export * from './media'
|
||||
export * from './notifications'
|
||||
export * from './playback'
|
||||
export * from './playProfiles'
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export type PlaybackQualitySource = 'cloud' | 'original' | 'local'
|
||||
|
||||
export interface PlaybackQuality {
|
||||
id: string
|
||||
label: string
|
||||
height?: number
|
||||
source: PlaybackQualitySource
|
||||
available: boolean
|
||||
requires_transcode?: boolean
|
||||
requires_vip?: boolean
|
||||
note?: string
|
||||
}
|
||||
|
||||
export interface PlaybackTranscodeState {
|
||||
state: 'idle' | 'ready' | 'transcoding' | 'unavailable'
|
||||
definition?: string
|
||||
message?: string
|
||||
retry_after_sec?: number
|
||||
started_at?: number
|
||||
}
|
||||
|
||||
export interface PlaybackInfo {
|
||||
media_id: string
|
||||
provider: string
|
||||
fallback: string[]
|
||||
default_quality: string
|
||||
cloud_qualities?: PlaybackQuality[]
|
||||
local_qualities: PlaybackQuality[]
|
||||
transcode: PlaybackTranscodeState
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user