mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86214ea796 | |||
| 774f2d4695 | |||
| efa64051ea | |||
| 5095347ace | |||
| ad9260f8fe | |||
| 4ae2502096 | |||
| c05b5259ef | |||
| da1fb02c9d | |||
| eb09251424 | |||
| 5584862352 |
+9
-23
@@ -12,10 +12,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
@@ -108,30 +105,19 @@ func main() {
|
||||
|
||||
router := buildRouter(cfg, logger, services)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.App.Port),
|
||||
Handler: router,
|
||||
ReadHeaderTimeout: 15 * time.Second,
|
||||
serverMgr := newServerManager(cfg, logger, router)
|
||||
services.ReloadHTTPServer = serverMgr.Reload
|
||||
if err := serverMgr.Start(); err != nil {
|
||||
logger.Fatal("listen failed", zap.Error(err))
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", srv.Addr)
|
||||
if err != nil {
|
||||
logger.Fatal("listen failed", zap.String("addr", srv.Addr), zap.Error(err))
|
||||
}
|
||||
localIP := getLocalIP()
|
||||
logger.Info("server is ready",
|
||||
zap.String("local", fmt.Sprintf("http://%s:%d", localIP, cfg.App.Port)),
|
||||
zap.String("listen", srv.Addr),
|
||||
)
|
||||
go func() {
|
||||
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
logger.Fatal("listen failed", zap.Error(err))
|
||||
scheme := "http"
|
||||
if cfg.App.HTTPSEnabled {
|
||||
scheme = "https"
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
if publicIP := getPublicIP(3 * time.Second); publicIP != "" {
|
||||
logger.Info("server public endpoint",
|
||||
zap.String("public", fmt.Sprintf("http://%s:%d", publicIP, cfg.App.Port)),
|
||||
zap.String("public", fmt.Sprintf("%s://%s:%d", scheme, publicIP, cfg.App.Port)),
|
||||
)
|
||||
}
|
||||
}()
|
||||
@@ -145,7 +131,7 @@ func main() {
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
if err := serverMgr.Shutdown(ctx); err != nil {
|
||||
logger.Error("graceful shutdown failed", zap.Error(err))
|
||||
}
|
||||
services.Close()
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
// tlsPair 记录当前正在服务的证书,用于判断是否需要重新绑定监听。
|
||||
type tlsPair struct {
|
||||
cert tls.Certificate
|
||||
certPEM string
|
||||
keyPEM string
|
||||
// version 是解析后的证书/私钥指纹;内容或磁盘文件变化都会导致其改变,
|
||||
// 据此决定是否需要重新绑定监听。
|
||||
version string
|
||||
}
|
||||
|
||||
// serverManager 负责 MMTL 的 HTTP/HTTPS 监听。HTTPS 设置保存后调用 Reload,
|
||||
// 在同一个端口上把明文 HTTP 与 TLS 监听热切换,无需重启进程:
|
||||
//
|
||||
// - 关闭旧监听释放端口(同一进程内 Windows 不允许重复绑定同一端口);
|
||||
// - 按最新配置重新绑定并立即对外服务;
|
||||
// - 旧服务器随后优雅退出,正在进行的播放/请求不会被立刻掐断。
|
||||
//
|
||||
// 任何校验失败都会中止切换并保留旧监听,保证用户不会被锁在服务外面。
|
||||
type serverManager struct {
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
handler http.Handler
|
||||
addr string
|
||||
|
||||
mu sync.Mutex
|
||||
srv *http.Server
|
||||
ln net.Listener
|
||||
pair *tlsPair
|
||||
stopCh chan struct{}
|
||||
autoReloadStarted bool
|
||||
}
|
||||
|
||||
func newServerManager(cfg *config.Config, log *zap.Logger, handler http.Handler) *serverManager {
|
||||
return &serverManager{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
handler: handler,
|
||||
addr: fmt.Sprintf(":%d", cfg.App.Port),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动监听。即使 HTTPS 配置损坏也退回明文 HTTP 继续启动,避免服务冷启动失败。
|
||||
func (m *serverManager) Start() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
pair, err := m.desiredPair()
|
||||
if err != nil {
|
||||
m.log.Error("invalid HTTPS config at startup, serving plain HTTP instead", zap.Error(err))
|
||||
pair = nil
|
||||
}
|
||||
if err := m.bind(pair); err != nil {
|
||||
return err
|
||||
}
|
||||
m.logServerReady()
|
||||
m.maybeStartAutoReloadLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reload 依据最新配置热切换监听。返回的错误会带给调用它的设置接口;若新监听
|
||||
// 绑定失败会自动回滚到旧配置继续服务。
|
||||
func (m *serverManager) Reload() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
pair, err := m.desiredPair()
|
||||
if err != nil {
|
||||
m.log.Error("server reload aborted", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
if m.pairEquals(pair) {
|
||||
return nil
|
||||
}
|
||||
|
||||
oldSrv, oldLn, oldPair := m.srv, m.ln, m.pair
|
||||
if oldLn != nil {
|
||||
_ = oldLn.Close() // 释放端口后再绑定新监听
|
||||
}
|
||||
m.srv, m.ln, m.pair = nil, nil, nil
|
||||
|
||||
firstErr := m.bind(pair)
|
||||
if firstErr != nil {
|
||||
m.log.Error("bind new listener failed, rolling back to previous", zap.Error(firstErr))
|
||||
if rbErr := m.bind(oldPair); rbErr != nil {
|
||||
return fmt.Errorf("reload failed: %v; rollback failed: %v", firstErr, rbErr)
|
||||
}
|
||||
}
|
||||
// 新监听已就绪,让旧服务器在新连接切换到新监听后优雅退出。
|
||||
m.drain(oldSrv)
|
||||
m.logServerReady()
|
||||
m.maybeStartAutoReloadLocked()
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// Shutdown 优雅停止当前服务器(用于进程退出)。
|
||||
func (m *serverManager) Shutdown(ctx context.Context) error {
|
||||
select {
|
||||
case <-m.stopCh:
|
||||
default:
|
||||
close(m.stopCh)
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.srv == nil {
|
||||
return nil
|
||||
}
|
||||
return m.srv.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// desiredPair 根据当前配置计算目标监听形态:nil 表示明文 HTTP,非 nil 表示 TLS。
|
||||
// 证书/私钥按"路径优先、内容兜底"解析,并校验是否匹配。
|
||||
func (m *serverManager) desiredPair() (*tlsPair, error) {
|
||||
if m.cfg == nil || !m.cfg.App.HTTPSEnabled {
|
||||
return nil, nil
|
||||
}
|
||||
certPEM, err := service.ResolveSSLMaterial(m.cfg.App.SSLCert, m.cfg.App.SSLCertPath, "证书")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyPEM, err := service.ResolveSSLMaterial(m.cfg.App.SSLKey, m.cfg.App.SSLKeyPath, "私钥")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := service.ValidateSSLKeyPair(certPEM, keyPEM); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cert, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSL 证书/私钥无效:%v", err)
|
||||
}
|
||||
return &tlsPair{
|
||||
cert: cert,
|
||||
certPEM: certPEM,
|
||||
keyPEM: keyPEM,
|
||||
version: certPEM + "\x00" + keyPEM,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// maybeStartAutoReloadLocked 在证书/私钥通过文件路径配置时,幂等地启动后台轮询,
|
||||
// 便于运行中切换到路径方式(或换证)后无需重启也能热更新。调用方需持有 m.mu。
|
||||
func (m *serverManager) maybeStartAutoReloadLocked() {
|
||||
if m.autoReloadStarted {
|
||||
return
|
||||
}
|
||||
if !m.pathBased() {
|
||||
return
|
||||
}
|
||||
m.autoReloadStarted = true
|
||||
m.startAutoReload()
|
||||
}
|
||||
|
||||
// pathBased 是否至少有一侧证书/私钥通过文件路径配置。
|
||||
func (m *serverManager) pathBased() bool {
|
||||
return strings.TrimSpace(m.cfg.App.SSLCertPath) != "" || strings.TrimSpace(m.cfg.App.SSLKeyPath) != ""
|
||||
}
|
||||
|
||||
// startAutoReload 后台轮询文件变更并自动热更新,方便换证。
|
||||
func (m *serverManager) startAutoReload() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
go func() {
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-m.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
if !m.pathBased() {
|
||||
continue // 路径已清空(改回内容配置),不再轮询
|
||||
}
|
||||
if err := m.Reload(); err != nil {
|
||||
m.log.Warn("periodic https reload failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// pairEquals 判断目标配置与当前监听是否一致,一致则无需重新绑定。
|
||||
func (m *serverManager) pairEquals(pair *tlsPair) bool {
|
||||
if pair == nil && m.pair == nil {
|
||||
return true
|
||||
}
|
||||
if pair == nil || m.pair == nil {
|
||||
return false
|
||||
}
|
||||
return pair.version == m.pair.version
|
||||
}
|
||||
|
||||
// bind 创建并按需启用 TLS 的监听,异步开始服务。
|
||||
func (m *serverManager) bind(pair *tlsPair) error {
|
||||
ln, err := net.Listen("tcp", m.addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen %s: %w", m.addr, err)
|
||||
}
|
||||
srv := &http.Server{
|
||||
Handler: m.handler,
|
||||
ReadHeaderTimeout: 15 * time.Second,
|
||||
}
|
||||
if pair != nil {
|
||||
ln = tls.NewListener(ln, &tls.Config{
|
||||
Certificates: []tls.Certificate{pair.cert},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
})
|
||||
}
|
||||
m.srv, m.ln, m.pair = srv, ln, pair
|
||||
go m.serve(srv, ln)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *serverManager) serve(s *http.Server, ln net.Listener) {
|
||||
if err := s.Serve(ln); err != nil &&
|
||||
!errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) {
|
||||
m.log.Fatal("listen failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// drain 让旧服务器在后台优雅退出(等待进行中的连接完成或在超时后强制关闭)。
|
||||
func (m *serverManager) drain(s *http.Server) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
go func(s *http.Server) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := s.Shutdown(ctx); err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||
m.log.Warn("drain old server failed", zap.Error(err))
|
||||
}
|
||||
}(s)
|
||||
}
|
||||
|
||||
func (m *serverManager) logServerReady() {
|
||||
scheme := "http"
|
||||
if m.pair != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
localIP := getLocalIP()
|
||||
m.log.Info("server is ready",
|
||||
zap.String("scheme", scheme),
|
||||
zap.String("local", fmt.Sprintf("%s://%s:%d", scheme, localIP, m.cfg.App.Port)),
|
||||
zap.String("listen", m.addr),
|
||||
)
|
||||
if m.pair != nil {
|
||||
m.log.Info("HTTPS is enabled; plain HTTP is no longer served on this port",
|
||||
zap.String("addr", m.addr),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
)
|
||||
|
||||
func makeTestPairPEM(t *testing.T) (certPEM, keyPEM string) {
|
||||
t.Helper()
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "localhost"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
DNSNames: []string{"localhost"},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certPEM = strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})))
|
||||
keyPEM = strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})))
|
||||
return certPEM, keyPEM
|
||||
}
|
||||
|
||||
func newTestServerManager(t *testing.T) *serverManager {
|
||||
t.Helper()
|
||||
cfg := &config.Config{}
|
||||
cfg.App.Port = 18081
|
||||
return newServerManager(cfg, zap.NewNop(), http.NewServeMux())
|
||||
}
|
||||
|
||||
func TestDesiredPairModes(t *testing.T) {
|
||||
m := newTestServerManager(t)
|
||||
|
||||
if p, err := m.desiredPair(); err != nil || p != nil {
|
||||
t.Fatalf("disabled should be nil pair, got p=%v err=%v", p, err)
|
||||
}
|
||||
|
||||
certPEM, keyPEM := makeTestPairPEM(t)
|
||||
m.cfg.App.HTTPSEnabled = true
|
||||
m.cfg.App.SSLCert, m.cfg.App.SSLKey = certPEM, keyPEM
|
||||
p, err := m.desiredPair()
|
||||
if err != nil || p == nil || p.version == "" {
|
||||
t.Fatalf("content pair failed: p=%v err=%v", p, err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
certPath, keyPath := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
|
||||
if err := os.WriteFile(certPath, []byte(certPEM), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, []byte(keyPEM), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.cfg.App.SSLCert, m.cfg.App.SSLKey = "", ""
|
||||
m.cfg.App.SSLCertPath, m.cfg.App.SSLKeyPath = certPath, keyPath
|
||||
p2, err := m.desiredPair()
|
||||
if err != nil || p2 == nil {
|
||||
t.Fatalf("path pair failed: %v", err)
|
||||
}
|
||||
|
||||
m.cfg.App.SSLKeyPath = filepath.Join(dir, "nope.pem")
|
||||
if _, err := m.desiredPair(); err == nil {
|
||||
t.Fatal("expected error when key file missing")
|
||||
}
|
||||
m.cfg.App.SSLKeyPath = keyPath
|
||||
|
||||
// 替换文件(换一套新的有效证书)后版本号应变化,触发热更新。
|
||||
newCert, newKey := makeTestPairPEM(t)
|
||||
if err := os.WriteFile(certPath, []byte(newCert), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, []byte(newKey), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p3, err := m.desiredPair()
|
||||
if err != nil {
|
||||
t.Fatalf("replace: %v", err)
|
||||
}
|
||||
if p3.version == p2.version {
|
||||
t.Fatal("version should change after files replaced")
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 830 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -49,6 +49,17 @@ type AppConfig struct {
|
||||
Env string `mapstructure:"env"`
|
||||
DataDir string `mapstructure:"data_dir"`
|
||||
WebDir string `mapstructure:"web_dir"`
|
||||
// HTTPSEnabled 是否仅通过 HTTPS 提供访问。启用时必须同时配置
|
||||
// SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS。
|
||||
HTTPSEnabled bool `mapstructure:"https_enabled"`
|
||||
// SSLCert 是 PEM 编码的 SSL 证书内容。
|
||||
SSLCert string `mapstructure:"ssl_cert"`
|
||||
// SSLKey 是 PEM 编码的 SSL 私钥内容。
|
||||
SSLKey string `mapstructure:"ssl_key"`
|
||||
// SSLCertPath 是 SSL 证书文件路径;非空时优先于 SSLCert 从文件读取。
|
||||
SSLCertPath string `mapstructure:"ssl_cert_path"`
|
||||
// SSLKeyPath 是 SSL 私钥文件路径;非空时优先于 SSLKey 从文件读取。
|
||||
SSLKeyPath string `mapstructure:"ssl_key_path"`
|
||||
FFmpegPath string `mapstructure:"ffmpeg_path"`
|
||||
FFprobePath string `mapstructure:"ffprobe_path"`
|
||||
// FFprobeMaxConcurrent limits concurrent ffprobe/ffmpeg metadata probes.
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
@@ -50,6 +51,10 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
|
||||
_ = svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.User{}).Where("hide_adult = ?", false).Update("hide_adult", true).Error
|
||||
}
|
||||
service.ApplyRuntimeSetting(svc.Cfg, req.Key, req.Value)
|
||||
if err := applyHTTPSetting(svc, req.Key, req.Value); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if svc.FFprobe != nil && (req.Key == "ffprobe.max_concurrent" || req.Key == "app.ffprobe_max_concurrent") {
|
||||
svc.FFprobe.SetMaxConcurrent(svc.Cfg.App.FFprobeMaxConcurrent)
|
||||
}
|
||||
@@ -63,6 +68,83 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// applyHTTPSetting 校验 HTTPS 相关设置,并在可行时热重载监听。
|
||||
// 必须在 ApplyRuntimeSetting 之后调用,这样 svc.Cfg 已反映刚保存的值。
|
||||
//
|
||||
// 规则:
|
||||
// - https.enabled=true 时强制要求证书与私钥都已配置(内容或路径均可)且匹配,
|
||||
// 否则返回错误("如果启用就必须配置 SSL 证书和密钥");
|
||||
// - 证书/私钥(内容或路径)单独保存时只校验格式;若 HTTPS 已开启且新的整体
|
||||
// 配置可解析匹配才触发重载,避免"只存了新证书、私钥还没保存"时用旧私钥带
|
||||
// 新证书对外提供服务。
|
||||
func applyHTTPSetting(svc *service.Container, key, value string) error {
|
||||
skipReload := func(reason string) {
|
||||
if svc.Log != nil {
|
||||
svc.Log.Warn("https setting saved but not applied yet", zap.String("key", key), zap.String("reason", reason))
|
||||
}
|
||||
}
|
||||
switch key {
|
||||
case "https.enabled":
|
||||
if svc.Cfg.App.HTTPSEnabled {
|
||||
if _, err := service.ResolveSSLKeyPair(svc.Cfg.App.SSLCert, svc.Cfg.App.SSLCertPath, svc.Cfg.App.SSLKey, svc.Cfg.App.SSLKeyPath); err != nil {
|
||||
return fmt.Errorf("启用 HTTPS 失败:%v", err)
|
||||
}
|
||||
}
|
||||
case "https.cert", "https.cert_path", "https.key", "https.key_path":
|
||||
if err := validateSSLMaterialSource(key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
if !svc.Cfg.App.HTTPSEnabled {
|
||||
return nil
|
||||
}
|
||||
if !httpsPairReady(svc) {
|
||||
skipReload("证书与私钥尚未匹配,等待另一半保存后生效")
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if svc.ReloadHTTPServer != nil {
|
||||
return svc.ReloadHTTPServer()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSSLMaterialSource 校验刚保存的证书/私钥来源(内容或路径)本身格式合法。
|
||||
func validateSSLMaterialSource(key, value string) error {
|
||||
switch key {
|
||||
case "https.cert":
|
||||
return service.ValidateSSLCert(value)
|
||||
case "https.cert_path":
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil // 清空路径也允许,启用时由整体校验把关
|
||||
}
|
||||
pemStr, err := service.ResolveSSLMaterial("", value, "证书")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return service.ValidateSSLCert(pemStr)
|
||||
case "https.key":
|
||||
return service.ValidateSSLKey(value)
|
||||
case "https.key_path":
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
pemStr, err := service.ResolveSSLMaterial("", value, "私钥")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return service.ValidateSSLKey(pemStr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// httpsPairReady 判断基于当前配置解析出的证书/私钥是否完整且匹配。
|
||||
func httpsPairReady(svc *service.Container) bool {
|
||||
_, err := service.ResolveSSLKeyPair(svc.Cfg.App.SSLCert, svc.Cfg.App.SSLCertPath, svc.Cfg.App.SSLKey, svc.Cfg.App.SSLKeyPath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
type testAdultScraperReq struct {
|
||||
Engine string `json:"engine"`
|
||||
ServerURL string `json:"server_url"`
|
||||
|
||||
@@ -85,7 +85,14 @@ func (p *openAPI115Provider) ResolveWithUA(ctx context.Context, fileRef, ua stri
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &DirectLink{URL: url, Proxy: false}, nil
|
||||
// 115 CDN 防盗链白名单:直链绑定换取时的 User-Agent(调用方 UA 或
|
||||
// DefaultUA),后续请求必须携带同一 UA,否则 403。302 播放由客户端
|
||||
// 自带 UA 天然满足;服务端直连(弹幕 hash 等)依赖这里的 Headers。
|
||||
bound := strings.TrimSpace(ua)
|
||||
if bound == "" {
|
||||
bound = cloud115.DefaultUA
|
||||
}
|
||||
return &DirectLink{URL: url, Proxy: false, Headers: map[string]string{"User-Agent": bound}}, nil
|
||||
}
|
||||
|
||||
// OpenClient 暴露底层客户端(token 刷新用)。
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/service/cloud"
|
||||
)
|
||||
|
||||
// overrideDanmakuOfficialBase points the "official" endpoint (match + fallback)
|
||||
@@ -91,6 +92,10 @@ func TestDanmakuFetchHashMatchLayer(t *testing.T) {
|
||||
require.Equal(t, "xml", res.SourceType)
|
||||
require.Contains(t, res.Raw, "弹幕Hash命中")
|
||||
require.Empty(t, res.Candidates)
|
||||
require.Equal(t, "测试动画", res.AnimeTitle)
|
||||
require.Equal(t, "第1话", res.EpisodeTitle)
|
||||
require.Equal(t, int64(25484), res.EpisodeID)
|
||||
require.Equal(t, "hash", res.MatchMode)
|
||||
|
||||
// match 请求体:文件名去扩展名并 URL 转义(官方接口要求,实测验证)、
|
||||
// hash、大小、matchMode 齐全。
|
||||
@@ -178,14 +183,21 @@ func TestDanmakuFetchHashMissFallsBackToFileNameSearch(t *testing.T) {
|
||||
}
|
||||
|
||||
// strm:通过解析出的直链 Range 拉 16MB 前缀算 hash → match → 拉弹幕。
|
||||
// range server 模拟 115 CDN 防盗链:UA 不匹配直接 403(真实部署中
|
||||
// 直链绑定换取时的 UA,不带绑定 UA 拉取会失败,见 pan115_openapi.go)。
|
||||
func TestDanmakuFetchStrmHashViaDirectLink(t *testing.T) {
|
||||
content := bytes.Repeat([]byte("strm-video-bytes-0123456789"), 400)
|
||||
sum := md5.Sum(content)
|
||||
wantHash := hex.EncodeToString(sum[:])
|
||||
|
||||
var gotRange string
|
||||
var gotRange, gotUA string
|
||||
rangeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotRange = r.Header.Get("Range")
|
||||
gotUA = r.Header.Get("User-Agent")
|
||||
if gotUA != "bound-ua-115" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write(content)
|
||||
}))
|
||||
@@ -202,7 +214,11 @@ func TestDanmakuFetchStrmHashViaDirectLink(t *testing.T) {
|
||||
var gotProvider string
|
||||
svc.SetStrmResolver(func(_ context.Context, provider string, _ url.Values) (*StrmPlayResult, error) {
|
||||
gotProvider = provider
|
||||
return &StrmPlayResult{RedirectURL: rangeSrv.URL}, nil
|
||||
// 播放链路 302 直链 + 绑定的 UA 头(防服务端直连 403)。
|
||||
return &StrmPlayResult{
|
||||
RedirectURL: rangeSrv.URL,
|
||||
Link: &cloud.DirectLink{URL: rangeSrv.URL, Headers: map[string]string{"User-Agent": "bound-ua-115"}},
|
||||
}, nil
|
||||
})
|
||||
ctx := context.Background()
|
||||
strmPath := filepath.Join(t.TempDir(), "远程动画.第01话.strm")
|
||||
@@ -220,6 +236,7 @@ func TestDanmakuFetchStrmHashViaDirectLink(t *testing.T) {
|
||||
require.Contains(t, res.Raw, "弹幕Strm命中")
|
||||
require.Equal(t, "115", gotProvider)
|
||||
require.Contains(t, gotRange, "bytes=0-")
|
||||
require.Equal(t, "bound-ua-115", gotUA) // 防盗链 UA 必须透传
|
||||
require.Contains(t, seen, `"fileHash":"`+wantHash+`"`)
|
||||
require.Contains(t, seen, `"fileName":"`+url.QueryEscape("远程动画.第01话")+`"`)
|
||||
// strm 的 SizeBytes 是文本大小,不参与 match。
|
||||
|
||||
@@ -67,11 +67,17 @@ type DanmakuRenderConfig struct {
|
||||
//
|
||||
// Candidates is non-nil when multiple anime matched the search and the player
|
||||
// must ask the user which one to use (disambiguation); Raw is empty then.
|
||||
// AnimeTitle, EpisodeTitle, EpisodeID and MatchMode provide matched danmaku
|
||||
// metadata so the player UI can display which episode was loaded.
|
||||
type DanmakuFetchResult struct {
|
||||
DanmakuRenderConfig
|
||||
SourceType string `json:"source_type"`
|
||||
Raw string `json:"raw,omitempty"`
|
||||
Candidates []DanmakuAnime `json:"candidates,omitempty"`
|
||||
SourceType string `json:"source_type"`
|
||||
Raw string `json:"raw,omitempty"`
|
||||
Candidates []DanmakuAnime `json:"candidates,omitempty"`
|
||||
AnimeTitle string `json:"anime_title,omitempty"`
|
||||
EpisodeTitle string `json:"episode_title,omitempty"`
|
||||
EpisodeID int64 `json:"episode_id,omitempty"`
|
||||
MatchMode string `json:"match_mode,omitempty"`
|
||||
}
|
||||
|
||||
// DanmakuAnime is one search hit (an anime) with its episode list, mirroring
|
||||
@@ -184,74 +190,90 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
|
||||
configured := strings.TrimRight(strings.TrimSpace(res.Source), "/")
|
||||
official := danmakuOfficialBase
|
||||
|
||||
// 手动指定弹幕库:跳过识别,直接拉取该库(自定义源失败回退官方)。
|
||||
if target := strings.TrimSpace(episodeID); target != "" {
|
||||
raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target)
|
||||
if err != nil {
|
||||
s.log.Warn("danmaku comment fetch failed", zap.String("media_id", mediaID), zap.String("episode_id", target), zap.Error(err))
|
||||
return res, err
|
||||
}
|
||||
res.Raw, res.SourceType = raw, st
|
||||
return res, nil
|
||||
}
|
||||
|
||||
term, media, err := s.searchTerms(ctx, mediaID)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
manualKeyword := strings.TrimSpace(keyword) != ""
|
||||
if kw := strings.TrimSpace(keyword); kw != "" {
|
||||
term.name = kw
|
||||
}
|
||||
if strings.TrimSpace(term.name) == "" {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
target := ""
|
||||
|
||||
// 1) hash 识别:始终走官方 /api/v2/match。
|
||||
if media != nil && media.Path != "" {
|
||||
if hash, ok := s.mediaHash(ctx, media); ok {
|
||||
fileSize := media.SizeBytes
|
||||
if strings.EqualFold(filepath.Ext(media.Path), ".strm") {
|
||||
fileSize = 0 // strm 行的 SizeBytes 是文本大小,不是视频大小
|
||||
}
|
||||
matches, err := s.matchOfficial(ctx, danmakuMatchFileName(media.Path), hash, fileSize, media.DurationSec)
|
||||
// 手动指定弹幕库:跳过识别,直接拉取该库(自定义源失败回退官方)。
|
||||
if target := strings.TrimSpace(episodeID); target != "" {
|
||||
raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target)
|
||||
if err != nil {
|
||||
s.log.Warn("danmaku hash match failed", zap.String("media_id", mediaID), zap.Error(err))
|
||||
} else if len(matches) > 0 {
|
||||
target = fmt.Sprintf("%d", matches[0].EpisodeID)
|
||||
s.log.Warn("danmaku comment fetch failed", zap.String("media_id", mediaID), zap.String("episode_id", target), zap.Error(err))
|
||||
return res, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 3 层)。
|
||||
if target == "" && !manualKeyword && media != nil && media.Path != "" {
|
||||
if fileName := danmakuMatchFileName(media.Path); fileName != "" && fileName != term.name {
|
||||
if candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, fileName, term.episode); err == nil &&
|
||||
len(candidates) == 1 && len(candidates[0].Episodes) > 0 {
|
||||
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
|
||||
res.Raw, res.SourceType = raw, st
|
||||
if id, parseErr := strconv.ParseInt(target, 10, 64); parseErr == nil {
|
||||
res.EpisodeID = id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数,
|
||||
// 多结果返回候选列表交给播放器(歧义处理)。
|
||||
if target == "" {
|
||||
candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, term.name, term.episode)
|
||||
if err != nil {
|
||||
s.log.Warn("danmaku search failed", zap.String("media_id", mediaID), zap.String("name", term.name), zap.String("episode", term.episode), zap.Error(err))
|
||||
return res, err
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
res.Candidates = candidates
|
||||
res.MatchMode = "manual"
|
||||
return res, nil
|
||||
}
|
||||
if len(candidates[0].Episodes) == 0 {
|
||||
return res, errors.New("no danmaku library found for this video")
|
||||
|
||||
term, media, err := s.searchTerms(ctx, mediaID)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
manualKeyword := strings.TrimSpace(keyword) != ""
|
||||
if kw := strings.TrimSpace(keyword); kw != "" {
|
||||
term.name = kw
|
||||
}
|
||||
if strings.TrimSpace(term.name) == "" {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
target := ""
|
||||
|
||||
// 1) hash 识别:始终走官方 /api/v2/match。
|
||||
if media != nil && media.Path != "" {
|
||||
if hash, ok := s.mediaHash(ctx, media); ok {
|
||||
fileSize := media.SizeBytes
|
||||
if strings.EqualFold(filepath.Ext(media.Path), ".strm") {
|
||||
fileSize = 0 // strm 行的 SizeBytes 是文本大小,不是视频大小
|
||||
}
|
||||
matches, err := s.matchOfficial(ctx, danmakuMatchFileName(media.Path), hash, fileSize, media.DurationSec)
|
||||
if err != nil {
|
||||
s.log.Warn("danmaku hash match failed", zap.String("media_id", mediaID), zap.Error(err))
|
||||
} else if len(matches) > 0 {
|
||||
target = fmt.Sprintf("%d", matches[0].EpisodeID)
|
||||
res.AnimeTitle = matches[0].AnimeTitle
|
||||
res.EpisodeTitle = matches[0].EpisodeTitle
|
||||
res.EpisodeID = matches[0].EpisodeID
|
||||
res.MatchMode = "hash"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 3 层)。
|
||||
if target == "" && !manualKeyword && media != nil && media.Path != "" {
|
||||
if fileName := danmakuMatchFileName(media.Path); fileName != "" && fileName != term.name {
|
||||
if candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, fileName, term.episode); err == nil &&
|
||||
len(candidates) == 1 && len(candidates[0].Episodes) > 0 {
|
||||
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
|
||||
res.AnimeTitle = candidates[0].AnimeTitle
|
||||
res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
|
||||
res.EpisodeID = candidates[0].Episodes[0].EpisodeID
|
||||
res.MatchMode = "filename"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数,
|
||||
// 多结果返回候选列表交给播放器(歧义处理)。
|
||||
if target == "" {
|
||||
candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, term.name, term.episode)
|
||||
if err != nil {
|
||||
s.log.Warn("danmaku search failed", zap.String("media_id", mediaID), zap.String("name", term.name), zap.String("episode", term.episode), zap.Error(err))
|
||||
return res, err
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
res.Candidates = candidates
|
||||
return res, nil
|
||||
}
|
||||
if len(candidates[0].Episodes) == 0 {
|
||||
return res, errors.New("no danmaku library found for this video")
|
||||
}
|
||||
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
|
||||
res.AnimeTitle = candidates[0].AnimeTitle
|
||||
res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
|
||||
res.EpisodeID = candidates[0].Episodes[0].EpisodeID
|
||||
res.MatchMode = "search"
|
||||
}
|
||||
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
|
||||
}
|
||||
|
||||
raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target)
|
||||
if err != nil {
|
||||
@@ -545,6 +567,8 @@ func (s *DanmakuService) hashStrmTarget(ctx context.Context, raw string) (string
|
||||
}
|
||||
src, err = s.strmResolve(ctx, segs[0], u.Query())
|
||||
if err != nil || src == nil {
|
||||
s.log.Warn("danmaku strm resolve failed, hash layer skipped",
|
||||
zap.String("provider", segs[0]), zap.Error(err))
|
||||
return "", false
|
||||
}
|
||||
case u.Scheme == "http" || u.Scheme == "https":
|
||||
@@ -557,13 +581,20 @@ func (s *DanmakuService) hashStrmTarget(ctx context.Context, raw string) (string
|
||||
case src.LocalPath != "":
|
||||
return s.hashLocalFile(src.LocalPath)
|
||||
case src.RedirectURL != "":
|
||||
body, err = s.openRangeBody(ctx, src.RedirectURL, nil)
|
||||
var headers map[string]string
|
||||
if src.Link != nil {
|
||||
headers = src.Link.Headers // 115 直链防盗链要求携带绑定 UA
|
||||
}
|
||||
body, err = s.openRangeBody(ctx, src.RedirectURL, headers)
|
||||
case src.Link != nil && src.Link.URL != "":
|
||||
body, err = s.openRangeBody(ctx, src.Link.URL, src.Link.Headers)
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
if err != nil || body == nil {
|
||||
if err != nil {
|
||||
s.log.Warn("danmaku hash prefix fetch failed, hash layer skipped", zap.String("target", raw), zap.Error(err))
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
@@ -142,6 +142,10 @@ func TestDanmakuFetchWithDandanplaySource(t *testing.T) {
|
||||
require.Equal(t, "xml", res.SourceType)
|
||||
require.Contains(t, res.Raw, "弹幕A")
|
||||
require.Contains(t, res.Raw, `p="0.5,1,16777215,user1"`)
|
||||
require.Equal(t, "测试动画", res.AnimeTitle)
|
||||
require.Equal(t, "第1话", res.EpisodeTitle)
|
||||
require.Equal(t, int64(25484), res.EpisodeID)
|
||||
require.Equal(t, "search", res.MatchMode)
|
||||
}
|
||||
|
||||
func TestDanmakuFetchUsesOriginalNameForSearch(t *testing.T) {
|
||||
@@ -262,6 +266,8 @@ func TestDanmakuFetchWithExplicitEpisodeID(t *testing.T) {
|
||||
require.True(t, res.Enabled)
|
||||
require.Contains(t, res.Raw, "显式指定弹幕")
|
||||
require.Empty(t, res.Candidates)
|
||||
require.Equal(t, int64(99999), res.EpisodeID)
|
||||
require.Equal(t, "manual", res.MatchMode)
|
||||
}
|
||||
func TestDetectDanmakuSourceType(t *testing.T) {
|
||||
cases := []struct {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ResolveSSLMaterial 解析一份 SSL 材料(证书或私钥)的 PEM 内容:
|
||||
// 优先读取 path 指向的文件,其次使用内容;两者都为空时返回错误。
|
||||
// what 用于错误提示("证书" / "私钥")。
|
||||
func ResolveSSLMaterial(content, path, what string) (string, error) {
|
||||
p := strings.TrimSpace(path)
|
||||
if p != "" {
|
||||
if info, err := os.Stat(p); err != nil {
|
||||
return "", fmt.Errorf("SSL %s文件不可访问:%s(%v)", what, p, err)
|
||||
} else if info.IsDir() {
|
||||
return "", fmt.Errorf("SSL %s路径指向的是目录,请填写文件路径:%s", what, p)
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取 SSL %s文件失败:%s(%v)", what, p, err)
|
||||
}
|
||||
return strings.TrimSpace(string(b)), nil
|
||||
}
|
||||
c := strings.TrimSpace(content)
|
||||
if c == "" {
|
||||
return "", fmt.Errorf("SSL %s未配置:请填写内容或文件路径", what)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// ResolveSSLKeyPair 解析证书与私钥(各自支持 内容或路径),校验格式与匹配后
|
||||
// 返回可用的 tls.Certificate。任何一步失败都会给出明确错误。
|
||||
func ResolveSSLKeyPair(certContent, certPath, keyContent, keyPath string) (*tls.Certificate, error) {
|
||||
certPEM, err := ResolveSSLMaterial(certContent, certPath, "证书")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyPEM, err := ResolveSSLMaterial(keyContent, keyPath, "私钥")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cert, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSL 证书/私钥无效:%v", err)
|
||||
}
|
||||
if !sslKeyMatchesCert(cert) {
|
||||
return nil, errors.New("SSL 证书与私钥不匹配")
|
||||
}
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
// PathFingerprint 返回文件路径的内容指纹(路径 + 大小 + 修改时间),用于检测
|
||||
// 文件是否被替换过;文件不存在时返回 ("", false)。
|
||||
func PathFingerprint(p string) (string, bool) {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
return "", false
|
||||
}
|
||||
info, err := os.Stat(p)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return fmt.Sprintf("path:%s|size:%d|mtime:%d", filepath.Clean(p), info.Size(), info.ModTime().UnixNano()), true
|
||||
}
|
||||
|
||||
// ValidateSSLCert 校验 s 是一个可解析的 PEM 编码 X.509 证书。
|
||||
func ValidateSSLCert(s string) error {
|
||||
block, _ := pem.Decode([]byte(strings.TrimSpace(s)))
|
||||
if block == nil {
|
||||
return errors.New("SSL 证书格式无效:未找到 PEM 数据")
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
return fmt.Errorf("SSL 证书格式无效:期望 CERTIFICATE,实际为 %s", block.Type)
|
||||
}
|
||||
if _, err := x509.ParseCertificate(block.Bytes); err != nil {
|
||||
return fmt.Errorf("SSL 证书解析失败:%v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateSSLKey 校验 s 是一个可解析的 PEM 编码私钥。
|
||||
func ValidateSSLKey(s string) error {
|
||||
block, _ := pem.Decode([]byte(strings.TrimSpace(s)))
|
||||
if block == nil {
|
||||
return errors.New("SSL 私钥格式无效:未找到 PEM 数据")
|
||||
}
|
||||
if _, err := parsePrivateKeyBlock(block); err != nil {
|
||||
return fmt.Errorf("SSL 私钥解析失败:%v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateSSLKeyPair 校验证书与私钥都存在、可解析且相互匹配。
|
||||
func ValidateSSLKeyPair(certPEM, keyPEM string) error {
|
||||
cert, err := tls.X509KeyPair([]byte(strings.TrimSpace(certPEM)), []byte(strings.TrimSpace(keyPEM)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("SSL 证书/私钥无效:%v", err)
|
||||
}
|
||||
if !sslKeyMatchesCert(cert) {
|
||||
return errors.New("SSL 证书与私钥不匹配")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sslKeyMatchesCert 通过公钥是否一致来判断私钥确实对应证书。
|
||||
func sslKeyMatchesCert(cert tls.Certificate) bool {
|
||||
if len(cert.Certificate) == 0 || cert.PrivateKey == nil {
|
||||
return false
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(cert.Certificate[0])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
privPub := publicKeyOf(cert.PrivateKey)
|
||||
if privPub == nil {
|
||||
return false
|
||||
}
|
||||
eq, ok := leaf.PublicKey.(interface {
|
||||
Equal(x crypto.PublicKey) bool
|
||||
})
|
||||
return ok && eq.Equal(privPub)
|
||||
}
|
||||
|
||||
// publicKeyOf 从各类私钥中提取对应的公钥。
|
||||
func publicKeyOf(priv crypto.PrivateKey) crypto.PublicKey {
|
||||
switch k := priv.(type) {
|
||||
case *rsa.PrivateKey:
|
||||
return &k.PublicKey
|
||||
case *ecdsa.PrivateKey:
|
||||
return &k.PublicKey
|
||||
case ed25519.PrivateKey:
|
||||
return k.Public()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parsePrivateKeyBlock 支持 PKCS#8 / PKCS#1 RSA / EC 三种常见私钥格式。
|
||||
func parsePrivateKeyBlock(block *pem.Block) (crypto.PrivateKey, error) {
|
||||
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
return nil, errors.New("无法解析私钥(支持 PKCS#8 / PKCS#1 RSA / EC)")
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func makeTestKeyPair(t *testing.T) (certPEM, keyPEM string) {
|
||||
t.Helper()
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "localhost"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
DNSNames: []string{"localhost"},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certPEM = strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})))
|
||||
keyPEM = strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})))
|
||||
return certPEM, keyPEM
|
||||
}
|
||||
|
||||
func TestResolveSSLMaterial(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPEM, _ := makeTestKeyPair(t)
|
||||
path := filepath.Join(dir, "cert.pem")
|
||||
if err := os.WriteFile(path, []byte(certPEM), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
content string
|
||||
path string
|
||||
want string
|
||||
err bool
|
||||
}{
|
||||
{name: "content only", content: certPEM, want: certPEM},
|
||||
{name: "path only", path: path, want: certPEM},
|
||||
{name: "path wins over content", content: "bogus", path: path, want: certPEM},
|
||||
{name: "both empty", err: true},
|
||||
{name: "missing file", path: filepath.Join(dir, "missing.pem"), err: true},
|
||||
{name: "path is dir", path: dir, err: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := ResolveSSLMaterial(tc.content, tc.path, "证书")
|
||||
if tc.err {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("got %q want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSSLKeyPair(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPEM, keyPEM := makeTestKeyPair(t)
|
||||
certPath := filepath.Join(dir, "cert.pem")
|
||||
keyPath := filepath.Join(dir, "key.pem")
|
||||
if err := os.WriteFile(certPath, []byte(certPEM), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, []byte(keyPEM), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := ResolveSSLKeyPair(certPEM, "", keyPEM, ""); err != nil {
|
||||
t.Fatalf("content pair: %v", err)
|
||||
}
|
||||
if _, err := ResolveSSLKeyPair("", certPath, "", keyPath); err != nil {
|
||||
t.Fatalf("path pair: %v", err)
|
||||
}
|
||||
if _, err := ResolveSSLKeyPair(certPEM, "", "", keyPath); err != nil {
|
||||
t.Fatalf("mixed pair: %v", err)
|
||||
}
|
||||
|
||||
otherCert, _ := makeTestKeyPair(t)
|
||||
if _, err := ResolveSSLKeyPair(otherCert, "", keyPEM, ""); err == nil {
|
||||
t.Fatal("expected mismatch error")
|
||||
}
|
||||
|
||||
if got, ok := PathFingerprint(certPath); !ok || got == "" {
|
||||
t.Fatalf("PathFingerprint failed: got=%q ok=%v", got, ok)
|
||||
}
|
||||
if _, ok := PathFingerprint(filepath.Join(dir, "missing.pem")); ok {
|
||||
t.Fatal("PathFingerprint should report missing file")
|
||||
}
|
||||
if _, ok := PathFingerprint(" "); ok {
|
||||
t.Fatal("empty PathFingerprint should not be ok")
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,16 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) {
|
||||
}
|
||||
case "transcode.video_bitrate", "transcoder.video_bitrate":
|
||||
cfg.Transcoder.VideoBitrate = value
|
||||
case "https.enabled":
|
||||
cfg.App.HTTPSEnabled = parseBoolSetting(value, false)
|
||||
case "https.cert":
|
||||
cfg.App.SSLCert = value
|
||||
case "https.key":
|
||||
cfg.App.SSLKey = value
|
||||
case "https.cert_path":
|
||||
cfg.App.SSLCertPath = strings.TrimSpace(value)
|
||||
case "https.key_path":
|
||||
cfg.App.SSLKeyPath = strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,10 @@ type Container struct {
|
||||
|
||||
stopCtx context.Context
|
||||
stopCancel context.CancelFunc
|
||||
|
||||
// ReloadHTTPServer 由 cmd/server 注入。HTTPS 相关设置保存后,handler
|
||||
// 会调用它把 HTTP/HTTPS 监听热切换到最新配置;nil 表示未注入(测试环境)。
|
||||
ReloadHTTPServer func() error
|
||||
}
|
||||
|
||||
// New 构建服务容器。
|
||||
|
||||
@@ -84,7 +84,9 @@ func (s *StrmService) resolveCloudPlay(ctx context.Context, provider string, q u
|
||||
if link.Proxy {
|
||||
return &StrmPlayResult{Link: link, Proxy: true}, nil
|
||||
}
|
||||
return &StrmPlayResult{RedirectURL: link.URL}, nil
|
||||
// Link 一并保留:302 处理器只认 RedirectURL,但服务端直连(弹幕 hash
|
||||
// 拉取)需要 link.Headers 才能通过直链防盗链校验。
|
||||
return &StrmPlayResult{RedirectURL: link.URL, Link: link}, nil
|
||||
}
|
||||
|
||||
// resolveLocalPlay 本地源:校验路径位于某个本地同步目录的源目录内。
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -28,6 +28,19 @@ export interface DanmakuFetchResult {
|
||||
area: string
|
||||
raw?: string
|
||||
candidates?: DanmakuAnime[]
|
||||
anime_title?: string
|
||||
episode_title?: string
|
||||
episode_id?: number
|
||||
match_mode?: 'hash' | 'filename' | 'search' | 'manual' | string
|
||||
}
|
||||
|
||||
export interface DanmakuLoadedInfo {
|
||||
animeTitle?: string
|
||||
episodeTitle?: string
|
||||
episodeId?: number | string
|
||||
matchMode?: 'hash' | 'filename' | 'search' | 'manual' | string
|
||||
totalCount: number
|
||||
sourceType?: 'auto' | 'xml' | 'json'
|
||||
}
|
||||
|
||||
export type DanmakuFetchOptions = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { create, type Manager, type ManagerPlugin } from 'danmu'
|
||||
|
||||
import { danmakuAPI, type DanmakuAnime } from '../api/danmaku'
|
||||
import { danmakuAPI, type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
|
||||
import type { Media } from '../types'
|
||||
import { parseDanmaku, type Comment } from '../utils/parseDanmaku'
|
||||
|
||||
@@ -28,8 +28,8 @@ type DanmakuStageProps = {
|
||||
search?: string | null
|
||||
/** Explicit danmaku library chosen by the user; null = auto-resolve. */
|
||||
episodeId?: number | string | null
|
||||
/** Called after each fetch attempt (success or error) finishes. */
|
||||
onLoaded?: () => void
|
||||
/** Called after each fetch attempt (success or error) finishes with metadata. */
|
||||
onLoaded?: (info: DanmakuLoadedInfo | null) => void
|
||||
/** Called when multiple anime matched and the user must pick one. */
|
||||
onCandidates?: (candidates: DanmakuAnime[]) => void
|
||||
}
|
||||
@@ -109,6 +109,14 @@ export function DanmakuStage({
|
||||
// 弹幕层不拦截播放器控制栏的点击。
|
||||
holder.style.pointerEvents = 'none'
|
||||
|
||||
// 监听 holder 尺寸变化(全屏/退出全屏/窗口缩放),实时重置弹道与容器边界
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (!disposed && managerRef.current) {
|
||||
managerRef.current.format()
|
||||
}
|
||||
})
|
||||
ro.observe(holder)
|
||||
|
||||
const applyLiveSettings = () => {
|
||||
const { opacity: liveOpacity, area: liveArea } = liveRef.current
|
||||
manager.setOpacity(liveOpacity)
|
||||
@@ -119,6 +127,7 @@ export function DanmakuStage({
|
||||
applyLiveSettings()
|
||||
|
||||
const loadDanmaku = async () => {
|
||||
let loadedInfo: DanmakuLoadedInfo | null = null
|
||||
try {
|
||||
const res = await danmakuAPI.fetch(media.id, {
|
||||
kw: search ?? undefined,
|
||||
@@ -136,14 +145,28 @@ export function DanmakuStage({
|
||||
.filter((c) => Number.isFinite(c.time) && c.time >= 0)
|
||||
.sort((a, b) => a.time - b.time)
|
||||
nextIndex = 0
|
||||
loadedInfo = {
|
||||
animeTitle: res.anime_title,
|
||||
episodeTitle: res.episode_title,
|
||||
episodeId: res.episode_id ?? (episodeId ? Number(episodeId) || episodeId : undefined),
|
||||
matchMode: res.match_mode,
|
||||
totalCount: comments.length,
|
||||
sourceType: res.source_type,
|
||||
}
|
||||
} else {
|
||||
comments = []
|
||||
loadedInfo = {
|
||||
totalCount: 0,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 拉取失败时静默关闭弹幕,不打断播放。
|
||||
comments = []
|
||||
loadedInfo = {
|
||||
totalCount: 0,
|
||||
}
|
||||
} finally {
|
||||
if (!disposed) onLoaded?.()
|
||||
if (!disposed) onLoaded?.(loadedInfo)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +238,7 @@ export function DanmakuStage({
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
ro.disconnect()
|
||||
cancelAnimationFrame(raf)
|
||||
video.removeEventListener('play', onPlay)
|
||||
video.removeEventListener('playing', onPlay)
|
||||
|
||||
@@ -50,6 +50,7 @@ export function Layout() {
|
||||
|
||||
const showSidebar = !isMediaView(location.pathname, location.search)
|
||||
const hideSearch = location.pathname.startsWith('/settings')
|
||||
const isPlayPage = location.pathname.startsWith('/play')
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-[var(--app-bg)] text-[var(--app-text)] font-body select-none">
|
||||
@@ -60,17 +61,19 @@ export function Layout() {
|
||||
showSidebar={showSidebar}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col min-w-0 overflow-hidden">
|
||||
<LayoutHeader
|
||||
permissions={permissions}
|
||||
theme={theme}
|
||||
onOpenMobileDrawer={() => sidebar.setIsMobileDrawerOpen(true)}
|
||||
user={user}
|
||||
activeProfileId={activeProfileId}
|
||||
profile={profile}
|
||||
onLogout={closeProfileAndLogout}
|
||||
showSidebar={showSidebar}
|
||||
hideSearch={hideSearch}
|
||||
/>
|
||||
{!isPlayPage && (
|
||||
<LayoutHeader
|
||||
permissions={permissions}
|
||||
theme={theme}
|
||||
onOpenMobileDrawer={() => sidebar.setIsMobileDrawerOpen(true)}
|
||||
user={user}
|
||||
activeProfileId={activeProfileId}
|
||||
profile={profile}
|
||||
onLogout={closeProfileAndLogout}
|
||||
showSidebar={showSidebar}
|
||||
hideSearch={hideSearch}
|
||||
/>
|
||||
)}
|
||||
<LayoutWorkspace routeKey={location.pathname} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -117,6 +117,16 @@ export function LayoutSidebars({
|
||||
}
|
||||
|
||||
export function LayoutWorkspace({ routeKey }: LayoutWorkspaceProps) {
|
||||
if (routeKey.startsWith('/play')) {
|
||||
return (
|
||||
<main className="flex flex-1 h-full w-full overflow-hidden">
|
||||
<RouteErrorBoundary>
|
||||
<Outlet />
|
||||
</RouteErrorBoundary>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
|
||||
@@ -50,7 +50,10 @@ export function PlayerControls({
|
||||
onToggleDanmaku,
|
||||
}: PlayerControlsProps) {
|
||||
const video = () => videoRef.current
|
||||
const container = () => videoRef.current?.parentElement ?? null
|
||||
const container = () =>
|
||||
videoRef.current?.closest<HTMLElement>('[data-player-stage]') ??
|
||||
videoRef.current?.parentElement ??
|
||||
null
|
||||
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, ChevronRight, Loader2, MessageSquareText, Search, X } from 'lucide-react'
|
||||
import { Check, ChevronRight, Film, Hash, Loader2, MessageSquareText, RefreshCw, Search, Sparkles, Tag, X } from 'lucide-react'
|
||||
|
||||
import type { DanmakuAnime, DanmakuEpisode } from '../api/danmaku'
|
||||
import type { DanmakuAnime, DanmakuEpisode, DanmakuLoadedInfo } from '../api/danmaku'
|
||||
|
||||
// PlayerDanmakuPanel — the on-player danmaku control panel. It toggles
|
||||
// loading, lets the user re-search by a custom keyword, and adjusts the
|
||||
// renderer knobs (display area / opacity / font size) live. Values are
|
||||
// controlled by PlayerPage so the panel is purely presentational.
|
||||
//
|
||||
// The knobs change immediately on drag; a "re-search" only fires when the
|
||||
// user clicks the search button (or presses Enter) so sliders don't trigger
|
||||
// network requests. When the backend returns multiple anime candidates
|
||||
// (disambiguation), the panel shows the picker so the user can choose the
|
||||
// right danmaku library by hand — mirroring danmaku-anywhere's selector.
|
||||
// PlayerDanmakuPanel — the on-player danmaku control panel. It displays
|
||||
// the matched danmaku details (anime title, episode title, comment count,
|
||||
// match mode), toggles loading, lets the user re-search by a custom keyword,
|
||||
// and adjusts the renderer knobs (display area / opacity / font size) live.
|
||||
type PlayerDanmakuPanelProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
@@ -30,7 +24,9 @@ type PlayerDanmakuPanelProps = {
|
||||
/** Multiple anime matched — user must pick one. */
|
||||
candidates: DanmakuAnime[]
|
||||
/** Human-readable label of the currently selected library. */
|
||||
selectedSource: string
|
||||
selectedSource?: string
|
||||
/** Loaded danmaku metadata (title, episode, count, match mode). */
|
||||
danmakuInfo?: DanmakuLoadedInfo | null
|
||||
onSelectEpisode: (episodeId: number, animeTitle: string, episodeTitle: string) => void
|
||||
onResetAuto: () => void
|
||||
}
|
||||
@@ -51,6 +47,7 @@ export function PlayerDanmakuPanel({
|
||||
onFontSizeChange,
|
||||
candidates,
|
||||
selectedSource,
|
||||
danmakuInfo,
|
||||
onSelectEpisode,
|
||||
onResetAuto,
|
||||
}: PlayerDanmakuPanelProps) {
|
||||
@@ -80,15 +77,49 @@ export function PlayerDanmakuPanel({
|
||||
})
|
||||
}
|
||||
|
||||
// 匹配模式标签显示辅助
|
||||
const renderMatchBadge = (mode?: string) => {
|
||||
switch (mode) {
|
||||
case 'hash':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5 rounded border border-emerald-500/30 bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-300">
|
||||
<Hash size={10} /> 哈希精准匹配
|
||||
</span>
|
||||
)
|
||||
case 'filename':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5 rounded border border-sky-500/30 bg-sky-500/15 px-1.5 py-0.5 text-[10px] font-medium text-sky-300">
|
||||
<Tag size={10} /> 文件名匹配
|
||||
</span>
|
||||
)
|
||||
case 'search':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5 rounded border border-violet-500/30 bg-violet-500/15 px-1.5 py-0.5 text-[10px] font-medium text-violet-300">
|
||||
<Sparkles size={10} /> 标题搜索匹配
|
||||
</span>
|
||||
)
|
||||
case 'manual':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5 rounded border border-amber-500/30 bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-medium text-amber-300">
|
||||
手动指定
|
||||
</span>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const isCustomOrManual = Boolean(search || selectedSource || danmakuInfo?.matchMode === 'manual')
|
||||
|
||||
return (
|
||||
// 面板悬浮于视频上方:阻止点击冒泡,避免触发视频区域的播放/暂停切换。
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute right-4 top-16 z-30 w-72 rounded-2xl border border-white/15 bg-black/80 p-4 text-white shadow-2xl backdrop-blur"
|
||||
className="absolute right-4 top-16 z-30 w-80 rounded-2xl border border-white/15 bg-black/85 p-4 text-white shadow-2xl backdrop-blur-md"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<MessageSquareText size={16} /> 弹幕设置
|
||||
<MessageSquareText size={16} className="text-rose-400" /> 弹幕设置
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -100,7 +131,7 @@ export function PlayerDanmakuPanel({
|
||||
</div>
|
||||
|
||||
{/* 是否加载弹幕 */}
|
||||
<label className="mb-3 flex cursor-pointer items-center justify-between text-sm">
|
||||
<label className="mb-3 flex cursor-pointer items-center justify-between rounded-lg bg-white/5 px-2.5 py-2 text-sm transition hover:bg-white/10">
|
||||
<span className="text-white/85">加载弹幕</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -110,23 +141,83 @@ export function PlayerDanmakuPanel({
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* 当前加载的弹幕信息卡片 */}
|
||||
{enabled && (
|
||||
<div className="mb-3">
|
||||
{searching ? (
|
||||
<div className="flex items-center justify-center gap-2 rounded-xl border border-white/10 bg-white/5 py-3 text-xs text-white/70">
|
||||
<Loader2 size={14} className="animate-spin text-rose-400" />
|
||||
<span>正在匹配弹幕…</span>
|
||||
</div>
|
||||
) : danmakuInfo && (danmakuInfo.totalCount > 0 || danmakuInfo.animeTitle) ? (
|
||||
<div className="rounded-xl border border-white/15 bg-white/5 p-2.5">
|
||||
<div className="mb-1 flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1 text-xs font-semibold text-white/95">
|
||||
<Film size={13} className="shrink-0 text-rose-400" />
|
||||
<span className="truncate" title={danmakuInfo.animeTitle || selectedSource || '未知番剧'}>
|
||||
{danmakuInfo.animeTitle || selectedSource || '未知番剧'}
|
||||
</span>
|
||||
</div>
|
||||
{danmakuInfo.episodeTitle && (
|
||||
<div className="mt-0.5 truncate pl-4 text-[11px] text-white/60" title={danmakuInfo.episodeTitle}>
|
||||
{danmakuInfo.episodeTitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isCustomOrManual && (
|
||||
<button
|
||||
onClick={onResetAuto}
|
||||
className="flex shrink-0 items-center gap-1 rounded bg-white/10 px-1.5 py-0.5 text-[10px] text-rose-300 transition hover:bg-white/15 hover:text-rose-200"
|
||||
title="清除手动搜索与选择,恢复自动匹配"
|
||||
>
|
||||
<RefreshCw size={10} />
|
||||
自动
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between border-t border-white/10 pt-1.5 text-[11px]">
|
||||
<div>{renderMatchBadge(danmakuInfo.matchMode)}</div>
|
||||
<div className="font-mono text-white/70">
|
||||
{danmakuInfo.totalCount > 0 ? `共 ${danmakuInfo.totalCount.toLocaleString()} 条弹幕` : '暂无弹幕内容'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : candidates.length === 0 ? (
|
||||
<div className="flex items-center justify-between rounded-xl border border-white/10 bg-white/5 px-3 py-2.5 text-xs text-white/50">
|
||||
<span>未匹配到弹幕,可在下方手动搜索</span>
|
||||
{isCustomOrManual && (
|
||||
<button
|
||||
onClick={onResetAuto}
|
||||
className="shrink-0 text-rose-300 transition hover:text-rose-200"
|
||||
title="恢复自动匹配"
|
||||
>
|
||||
恢复自动
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜索弹幕 */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-1 text-xs text-white/60">搜索弹幕(留空 = 按视频名)</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="mb-1 text-xs text-white/60">搜索弹幕(留空 = 按视频名自动匹配)</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onSearch(draft.trim())
|
||||
}}
|
||||
placeholder="输入番剧名…"
|
||||
className="min-w-0 flex-1 rounded-lg border border-white/15 bg-white/5 px-2.5 py-1.5 text-sm outline-none placeholder:text-white/35 focus:border-rose-400/60"
|
||||
placeholder="输入番剧或电影名…"
|
||||
className="min-w-0 flex-1 rounded-lg border border-white/15 bg-white/5 px-2.5 py-1.5 text-xs outline-none placeholder:text-white/35 focus:border-rose-400/60"
|
||||
/>
|
||||
<button
|
||||
onClick={() => onSearch(draft.trim())}
|
||||
disabled={searching}
|
||||
className="flex items-center gap-1 rounded-lg bg-rose-500/90 px-2.5 py-1.5 text-xs font-medium transition hover:bg-rose-500 disabled:opacity-50"
|
||||
className="flex items-center gap-1 rounded-lg bg-rose-500 px-2.5 py-1.5 text-xs font-medium text-white transition hover:bg-rose-600 disabled:opacity-50"
|
||||
>
|
||||
{searching ? <Loader2 size={13} className="animate-spin" /> : <Search size={13} />}
|
||||
搜索
|
||||
@@ -134,33 +225,18 @@ export function PlayerDanmakuPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 当前来源 / 候选手动选取 */}
|
||||
{selectedSource && (
|
||||
<div className="mb-3 flex items-center justify-between rounded-lg border border-white/10 bg-white/5 px-2.5 py-1.5 text-xs">
|
||||
<span className="min-w-0 truncate text-white/75" title={selectedSource}>
|
||||
当前: {selectedSource}
|
||||
</span>
|
||||
<button
|
||||
onClick={onResetAuto}
|
||||
className="ml-2 shrink-0 text-rose-300 transition hover:text-rose-200"
|
||||
title="清除手动选择,恢复自动匹配"
|
||||
>
|
||||
自动
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 多番剧命中候选列表 */}
|
||||
{candidates.length > 0 && (
|
||||
<div className="mb-4 rounded-lg border border-amber-400/25 bg-amber-400/5 p-2">
|
||||
<div className="mb-4 rounded-xl border border-amber-400/25 bg-amber-400/5 p-2.5">
|
||||
<div className="mb-1.5 px-1 text-xs font-medium text-amber-200">
|
||||
搜到多部番剧,请选择弹幕来源:
|
||||
搜到多部番剧,请选择对应集数:
|
||||
</div>
|
||||
<div className="max-h-52 overflow-y-auto pr-1">
|
||||
{candidates.map((anime, i) => (
|
||||
<div key={anime.animeId} className="mb-1">
|
||||
<button
|
||||
onClick={() => toggleAnime(anime.animeId)}
|
||||
className="flex w-full items-center gap-1 rounded-md px-1.5 py-1 text-left text-sm text-white/85 transition hover:bg-white/10"
|
||||
className="flex w-full items-center gap-1 rounded-md px-1.5 py-1 text-left text-xs font-medium text-white/85 transition hover:bg-white/10"
|
||||
>
|
||||
<ChevronRight
|
||||
size={13}
|
||||
@@ -170,8 +246,7 @@ export function PlayerDanmakuPanel({
|
||||
}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{anime.animeTitle}
|
||||
{anime.animeTitle === '' && `番剧 ${i + 1}`}
|
||||
{anime.animeTitle || `番剧 ${i + 1}`}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-white/40">
|
||||
{anime.episodes.length} 集
|
||||
@@ -248,7 +323,7 @@ function SliderRow({
|
||||
onChange: (v: number) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<div className="mb-2.5">
|
||||
<div className="mb-1 flex items-center justify-between text-xs">
|
||||
<span className="text-white/60">{label}</span>
|
||||
<span className="font-mono text-white/85">{format(value)}</span>
|
||||
|
||||
Vendored
+16
-11
@@ -328,19 +328,24 @@ body {
|
||||
}
|
||||
|
||||
/* ── 播放器字幕(WebVTT <track>)──
|
||||
去掉浏览器默认黑底,用文字阴影保证亮画面下的可读性;
|
||||
font 简写携带 line-height 收紧两行字幕的行距(默认行距偏大)。 */
|
||||
video::cue {
|
||||
background-color: transparent;
|
||||
color: #fff;
|
||||
font:
|
||||
500 1.15em/1.35 "PingFang SC",
|
||||
彻底去除浏览器 User Agent 默认的半透明黑底背景框,使用文字阴影保证亮暗画面下的可读性。 */
|
||||
::cue,
|
||||
video::cue,
|
||||
::cue(*),
|
||||
video::cue(*) {
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
color: #ffffff !important;
|
||||
font-family:
|
||||
"PingFang SC",
|
||||
"Microsoft YaHei",
|
||||
"Noto Sans CJK SC",
|
||||
"Source Han Sans SC",
|
||||
sans-serif;
|
||||
sans-serif !important;
|
||||
font-weight: 500 !important;
|
||||
line-height: 1.35 !important;
|
||||
text-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.9),
|
||||
0 0 8px rgba(0, 0, 0, 0.55),
|
||||
0 0 16px rgba(0, 0, 0, 0.35);
|
||||
0 1px 3px rgba(0, 0, 0, 0.95),
|
||||
0 0 8px rgba(0, 0, 0, 0.8),
|
||||
0 0 16px rgba(0, 0, 0, 0.6) !important;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import toast from 'react-hot-toast'
|
||||
|
||||
import { mediaAPI } from '../api/library'
|
||||
import { api, hlsURL, streamURL } from '../api/client'
|
||||
import { danmakuAPI, type DanmakuAnime } from '../api/danmaku'
|
||||
import { danmakuAPI, type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
|
||||
import { playbackAPI } from '../api/playback'
|
||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
import { systemAPI } from '../api/system'
|
||||
@@ -72,6 +72,8 @@ export function PlayerPage() {
|
||||
const [danmakuEpisodeId, setDanmakuEpisodeId] = useState<number | string | null>(null)
|
||||
// 自动匹配歧义(多番剧命中)时的候选列表。
|
||||
const [danmakuCandidates, setDanmakuCandidates] = useState<DanmakuAnime[]>([])
|
||||
// 已加载弹幕的元数据信息(番剧名、单集名、条数、匹配模式等)。
|
||||
const [danmakuInfo, setDanmakuInfo] = useState<DanmakuLoadedInfo | null>(null)
|
||||
// 用户当前选定的弹幕来源描述(面板中展示)。
|
||||
const [danmakuSelectedSource, setDanmakuSelectedSource] = useState('')
|
||||
const [danmakuOpacity, setDanmakuOpacity] = useState(1)
|
||||
@@ -129,17 +131,20 @@ export function PlayerPage() {
|
||||
setDanmakuSearching(true)
|
||||
setDanmakuCandidates([])
|
||||
setDanmakuEpisodeId(null)
|
||||
setDanmakuInfo(null)
|
||||
setDanmakuSearch(kw || null)
|
||||
}, [])
|
||||
|
||||
const danmakuLoaded = useCallback(() => {
|
||||
const danmakuLoaded = useCallback((info: DanmakuLoadedInfo | null) => {
|
||||
setDanmakuSearching(false)
|
||||
setDanmakuInfo(info)
|
||||
}, [])
|
||||
|
||||
// 多番剧命中(disambiguation):展示候选让用户选择。
|
||||
const danmakuGotCandidates = useCallback((candidates: DanmakuAnime[]) => {
|
||||
setDanmakuCandidates(candidates)
|
||||
setDanmakuSearching(false)
|
||||
setDanmakuInfo(null)
|
||||
// 候选是静默返回的(此时没有任何弹幕);自动打开面板提示用户选择来源。
|
||||
if (candidates.length > 0) setDanmakuOpen(true)
|
||||
}, [])
|
||||
@@ -150,7 +155,7 @@ export function PlayerPage() {
|
||||
setDanmakuCandidates([])
|
||||
setDanmakuSearching(true)
|
||||
// 展示当前所选来源(面板标题处可见)。
|
||||
setDanmakuSelectedSource(`${animeTitle}・${episodeTitle}`)
|
||||
setDanmakuSelectedSource(episodeTitle ? `${animeTitle}・${episodeTitle}` : animeTitle)
|
||||
}, [])
|
||||
|
||||
// 回到自动匹配(清除用户手动选择)。
|
||||
@@ -159,6 +164,8 @@ export function PlayerPage() {
|
||||
setDanmakuCandidates([])
|
||||
setDanmakuSearching(true)
|
||||
setDanmakuSearch(null)
|
||||
setDanmakuSelectedSource('')
|
||||
setDanmakuInfo(null)
|
||||
}, [])
|
||||
|
||||
// Load metadata and pick a default mode.
|
||||
@@ -304,7 +311,7 @@ export function PlayerPage() {
|
||||
}, [directOnly, hlsUnavailable, mode, params, setParams])
|
||||
|
||||
return (
|
||||
<div className="relative -m-6 flex min-h-screen flex-col overflow-hidden bg-black md:-m-8">
|
||||
<div className="relative flex h-full w-full flex-1 flex-col overflow-hidden bg-black">
|
||||
<PlayerTopBar
|
||||
directOnly={directOnly}
|
||||
mode={mode}
|
||||
@@ -346,6 +353,7 @@ export function PlayerPage() {
|
||||
onFontSizeChange={setDanmakuFontSize}
|
||||
candidates={danmakuCandidates}
|
||||
selectedSource={danmakuSelectedSource}
|
||||
danmakuInfo={danmakuInfo}
|
||||
onSelectEpisode={danmakuSelectEpisode}
|
||||
onResetAuto={danmakuResetAuto}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
|
||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
import { type DanmakuAnime } from '../api/danmaku'
|
||||
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
|
||||
import type { Media } from '../types'
|
||||
import { DanmakuStage } from '../components/DanmakuStage'
|
||||
import { PlayerControls } from '../components/PlayerControls'
|
||||
@@ -24,7 +24,7 @@ type PlayerVideoStageProps = {
|
||||
danmakuEpisodeId: number | string | null
|
||||
danmakuOpen: boolean
|
||||
onToggleDanmaku: () => void
|
||||
onDanmakuLoaded: () => void
|
||||
onDanmakuLoaded: (info: DanmakuLoadedInfo | null) => void
|
||||
onDanmakuCandidates: (candidates: DanmakuAnime[]) => void
|
||||
/** Danmaku settings panel; rendered inside the stage so it stays visible in fullscreen. */
|
||||
danmakuPanel: ReactNode
|
||||
@@ -50,6 +50,47 @@ export function PlayerVideoStage({
|
||||
onDanmakuCandidates,
|
||||
danmakuPanel,
|
||||
}: PlayerVideoStageProps) {
|
||||
const stageRef = useRef<HTMLDivElement>(null)
|
||||
const [videoRatio, setVideoRatio] = useState<number | null>(null)
|
||||
const [stageRect, setStageRect] = useState<{ width: number; height: number } | null>(null)
|
||||
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
|
||||
const [activeCueText, setActiveCueText] = useState<string>('')
|
||||
|
||||
// 监听舞台容器的真实尺寸(响应窗口大小调整和全屏切换)
|
||||
useEffect(() => {
|
||||
const stage = stageRef.current
|
||||
if (!stage) return
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry) {
|
||||
setStageRect({
|
||||
width: entry.contentRect.width,
|
||||
height: entry.contentRect.height,
|
||||
})
|
||||
}
|
||||
})
|
||||
ro.observe(stage)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
// 监听视频元数据加载,获取真实画面宽高比
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
const updateRatio = () => {
|
||||
if (video.videoWidth && video.videoHeight) {
|
||||
setVideoRatio(video.videoWidth / video.videoHeight)
|
||||
}
|
||||
}
|
||||
updateRatio()
|
||||
video.addEventListener('loadedmetadata', updateRatio)
|
||||
video.addEventListener('resize', updateRatio)
|
||||
return () => {
|
||||
video.removeEventListener('loadedmetadata', updateRatio)
|
||||
video.removeEventListener('resize', updateRatio)
|
||||
}
|
||||
}, [videoRef, media])
|
||||
|
||||
// 点击视频切换播放/暂停;双击切换全屏(控制栏事件自行阻止冒泡)。
|
||||
const togglePlay = () => {
|
||||
const video = videoRef.current
|
||||
@@ -58,66 +99,183 @@ export function PlayerVideoStage({
|
||||
else video.pause()
|
||||
}
|
||||
const toggleFullscreen = () => {
|
||||
const stage = videoRef.current?.parentElement
|
||||
const stage = stageRef.current
|
||||
if (!stage) return
|
||||
if (document.fullscreenElement) void document.exitFullscreen()
|
||||
else void stage.requestFullscreen?.()
|
||||
}
|
||||
|
||||
// 把用户选择的字幕轨道应用到 <video> 的 textTracks(跨浏览器显式设置
|
||||
// mode;<track default> 只影响初始值,部分浏览器不会自动显示)。
|
||||
// 自定义字幕驱动逻辑:
|
||||
// 把所选轨道设为 mode = 'hidden'(让浏览器在后台静默解析时间轴,但不渲染原生带黑底的字幕框),
|
||||
// 由下方的 React 自定义层输出 100% 纯透明背景、高清晰文字阴影的字幕。
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video || subs.length === 0) return
|
||||
const apply = () => {
|
||||
const tracks = video.textTracks
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
tracks[i].mode = i === subtitleIndex ? 'showing' : 'disabled'
|
||||
if (!video || subs.length === 0 || subtitleIndex < 0 || !subs[subtitleIndex]) {
|
||||
setActiveCueText('')
|
||||
return
|
||||
}
|
||||
|
||||
const timers = new Set<ReturnType<typeof setTimeout>>()
|
||||
const delay = (fn: () => void, ms: number) => {
|
||||
const id = setTimeout(fn, ms)
|
||||
timers.add(id)
|
||||
}
|
||||
|
||||
const updateCue = () => {
|
||||
const trackEls = Array.from(video.querySelectorAll('track'))
|
||||
const selectedEl = trackEls[subtitleIndex]
|
||||
const tt = selectedEl?.track
|
||||
if (!tt) {
|
||||
setActiveCueText('')
|
||||
return
|
||||
}
|
||||
|
||||
if (tt.activeCues && tt.activeCues.length > 0) {
|
||||
const texts: string[] = []
|
||||
for (let i = 0; i < tt.activeCues.length; i++) {
|
||||
const cue = tt.activeCues[i] as VTTCue
|
||||
if (cue && cue.text) texts.push(cue.text)
|
||||
}
|
||||
setActiveCueText(texts.join('\n'))
|
||||
} else {
|
||||
setActiveCueText('')
|
||||
}
|
||||
}
|
||||
|
||||
const apply = () => {
|
||||
const trackEls = Array.from(video.querySelectorAll('track'))
|
||||
if (trackEls.length === 0) return
|
||||
trackEls.forEach((el, i) => {
|
||||
const tt = el.track
|
||||
if (tt) {
|
||||
// 'hidden' 模式:浏览器解析 WebVTT 并触发 cuechange,但隐藏原生黑底 UI
|
||||
tt.mode = i === subtitleIndex ? 'hidden' : 'disabled'
|
||||
}
|
||||
})
|
||||
|
||||
const selected = trackEls[subtitleIndex]
|
||||
if (!selected) return
|
||||
|
||||
const tt = selected.track
|
||||
if (tt) {
|
||||
tt.removeEventListener('cuechange', updateCue)
|
||||
tt.addEventListener('cuechange', updateCue)
|
||||
}
|
||||
|
||||
// 竞态兜底重载检测
|
||||
delay(() => {
|
||||
const curTt = selected.track
|
||||
if (curTt && curTt.mode === 'hidden' && (!curTt.cues || curTt.cues.length === 0)) {
|
||||
const src = selected.getAttribute('src')
|
||||
if (src) {
|
||||
selected.setAttribute('src', '')
|
||||
selected.setAttribute('src', src)
|
||||
curTt.mode = 'hidden'
|
||||
}
|
||||
}
|
||||
}, 1200)
|
||||
|
||||
updateCue()
|
||||
}
|
||||
|
||||
apply()
|
||||
// 轨道元数据就绪后再应用一次,确保字幕真正可见
|
||||
video.addEventListener('loadedmetadata', apply)
|
||||
return () => video.removeEventListener('loadedmetadata', apply)
|
||||
video.addEventListener('timeupdate', updateCue)
|
||||
video.addEventListener('seeking', updateCue)
|
||||
video.addEventListener('seeked', updateCue)
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('loadedmetadata', apply)
|
||||
video.removeEventListener('timeupdate', updateCue)
|
||||
video.removeEventListener('seeking', updateCue)
|
||||
video.removeEventListener('seeked', updateCue)
|
||||
timers.forEach(clearTimeout)
|
||||
const trackEls = Array.from(video.querySelectorAll('track'))
|
||||
const selected = trackEls[subtitleIndex]
|
||||
if (selected?.track) {
|
||||
selected.track.removeEventListener('cuechange', updateCue)
|
||||
}
|
||||
}
|
||||
}, [subtitleIndex, subs, videoRef])
|
||||
|
||||
// 根据视频画面宽高比与舞台宽高比,确定视频在哪个轴向撑满 100%
|
||||
const isWiderThanStage =
|
||||
videoRatio && stageRect && stageRect.height > 0
|
||||
? videoRatio > stageRect.width / stageRect.height
|
||||
: true
|
||||
|
||||
const wrapperStyle = videoRatio
|
||||
? {
|
||||
aspectRatio: `${videoRatio}`,
|
||||
width: isWiderThanStage ? '100%' : 'auto',
|
||||
height: isWiderThanStage ? 'auto' : '100%',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
}
|
||||
: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex flex-1 items-center justify-center overflow-hidden bg-black"
|
||||
ref={stageRef}
|
||||
data-player-stage
|
||||
className="relative flex h-full w-full flex-1 items-center justify-center overflow-hidden bg-black"
|
||||
onClick={togglePlay}
|
||||
onDoubleClick={toggleFullscreen}
|
||||
>
|
||||
{media ? (
|
||||
<>
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
className="relative z-0 max-h-screen w-full max-w-[1600px] bg-black"
|
||||
onError={onVideoError}
|
||||
<div
|
||||
className="relative flex items-center justify-center overflow-hidden"
|
||||
style={wrapperStyle}
|
||||
>
|
||||
{subs.map((track) => (
|
||||
<track
|
||||
key={track.path}
|
||||
kind="subtitles"
|
||||
src={subtitlesAPI.url(media.id, track.path)}
|
||||
srcLang={track.lang}
|
||||
label={track.label || track.lang}
|
||||
/>
|
||||
))}
|
||||
</video>
|
||||
<DanmakuStage
|
||||
media={media}
|
||||
videoRef={videoRef}
|
||||
enabled={danmakuEnabled}
|
||||
opacity={danmakuOpacity}
|
||||
fontSize={danmakuFontSize}
|
||||
area={danmakuArea}
|
||||
search={danmakuSearch}
|
||||
episodeId={danmakuEpisodeId}
|
||||
onLoaded={onDanmakuLoaded}
|
||||
onCandidates={onDanmakuCandidates}
|
||||
/>
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
className="h-full w-full object-contain bg-black"
|
||||
onError={onVideoError}
|
||||
>
|
||||
{subs.map((track) => (
|
||||
<track
|
||||
key={track.path}
|
||||
kind="subtitles"
|
||||
src={subtitlesAPI.url(media.id, track.path)}
|
||||
srcLang={track.lang}
|
||||
label={track.label || track.lang}
|
||||
/>
|
||||
))}
|
||||
</video>
|
||||
<DanmakuStage
|
||||
media={media}
|
||||
videoRef={videoRef}
|
||||
enabled={danmakuEnabled}
|
||||
opacity={danmakuOpacity}
|
||||
fontSize={danmakuFontSize}
|
||||
area={danmakuArea}
|
||||
search={danmakuSearch}
|
||||
episodeId={danmakuEpisodeId}
|
||||
onLoaded={onDanmakuLoaded}
|
||||
onCandidates={onDanmakuCandidates}
|
||||
/>
|
||||
{/* 自定义沉浸式字幕层:纯透明背景 + 柔和阴影,完全消除浏览器原生黑框 */}
|
||||
{activeCueText ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-4 sm:bottom-6 md:bottom-8 z-10 flex justify-center text-center px-4">
|
||||
<span
|
||||
className="inline-block max-w-[92%] whitespace-pre-line text-center font-sans font-medium text-white text-base sm:text-lg md:text-xl lg:text-2xl select-none"
|
||||
style={{
|
||||
textShadow:
|
||||
'0 1px 3px rgba(0, 0, 0, 0.95), 0 0 8px rgba(0, 0, 0, 0.85), 0 0 16px rgba(0, 0, 0, 0.65)',
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
>
|
||||
{activeCueText}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<PlayerControls
|
||||
videoRef={videoRef}
|
||||
subs={subs}
|
||||
@@ -139,4 +297,4 @@ export function PlayerVideoStage({
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,18 +63,65 @@ export function SettingsPage() {
|
||||
const onSave = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (dirty.size === 0) return
|
||||
|
||||
const wantHTTPS = values['https.enabled'] === 'true' || values['https.enabled'] === '1'
|
||||
// 证书/私钥任一来源可用即可:路径优先,其次粘贴的内容。
|
||||
const materialOK = (content?: string, path?: string) =>
|
||||
Boolean((path ?? '').trim() || (content ?? '').trim())
|
||||
if (wantHTTPS && !(materialOK(values['https.cert'], values['https.cert_path']) &&
|
||||
materialOK(values['https.key'], values['https.key_path']))) {
|
||||
toast.error('启用 HTTPS 前请先填写 SSL 证书和私钥(内容或路径任选其一)')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
// Backend exposes a single-key updater; loop through dirty keys.
|
||||
for (const key of dirty) {
|
||||
await adminAPI.updateSetting(key, values[key] ?? '')
|
||||
// 证书/私钥(内容与路径)先保存、启用开关最后保存,后端校验开关时才能读到最新的配置。
|
||||
const rank = (k: string) =>
|
||||
k === 'https.cert' ||
|
||||
k === 'https.key' ||
|
||||
k === 'https.cert_path' ||
|
||||
k === 'https.key_path'
|
||||
? 0
|
||||
: k === 'https.enabled'
|
||||
? 2
|
||||
: 1
|
||||
const orderedKeys = [...dirty].sort((a, b) => rank(a) - rank(b))
|
||||
|
||||
const failures: string[] = []
|
||||
for (const key of orderedKeys) {
|
||||
try {
|
||||
await adminAPI.updateSetting(key, values[key] ?? '')
|
||||
} catch (err) {
|
||||
failures.push(
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
`保存 ${key} 失败`,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
toast.error(failures[0])
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(`已保存 ${dirty.size} 项配置`)
|
||||
setDirty(new Set())
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败'
|
||||
toast.error(msg)
|
||||
|
||||
// 切换 HTTPS 后(或关闭 HTTPS 后)连接会短暂中断,自动跳转到对应协议地址。
|
||||
const currentIsHTTPS = window.location.protocol === 'https:'
|
||||
const targetProto = wantHTTPS ? 'https:' : 'http:'
|
||||
if (wantHTTPS !== currentIsHTTPS) {
|
||||
toast(wantHTTPS ? '正在切换到 HTTPS 访问…' : '正在切换回 HTTP 访问…')
|
||||
window.setTimeout(() => {
|
||||
const url =
|
||||
targetProto +
|
||||
'//' +
|
||||
window.location.host +
|
||||
window.location.pathname +
|
||||
window.location.search
|
||||
window.location.replace(url)
|
||||
}, 800)
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
@@ -97,5 +97,40 @@ export const generalSettingsGroup: SettingGroup = {
|
||||
hint: 'NAS 建议 1;用于扫描、整理洗版和手动探测,避免同时启动多个 ffprobe 进程',
|
||||
defaultValue: '1',
|
||||
},
|
||||
{
|
||||
key: 'https.enabled',
|
||||
label: '启用 HTTPS',
|
||||
type: 'toggle',
|
||||
hint: '开启后服务将仅通过 HTTPS 访问,保存后会自动切换到 https 地址,原 http 地址不再提供服务。必须先在下文配置好 SSL 证书与私钥(内容或路径任选其一,两者需匹配)才能启用;首次切换因浏览器以 origin(协议+域名+端口)隔离登录态,需要重新登录一次。',
|
||||
defaultValue: 'false',
|
||||
},
|
||||
{
|
||||
key: 'https.cert',
|
||||
label: 'SSL 证书 (PEM)',
|
||||
type: 'textarea',
|
||||
hint: '将证书内容(-----BEGIN CERTIFICATE----- 至 -----END CERTIFICATE-----)整体粘贴到此处;也可留空,改在下方填写证书路径。',
|
||||
placeholder: '-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----',
|
||||
},
|
||||
{
|
||||
key: 'https.key',
|
||||
label: 'SSL 私钥 (PEM)',
|
||||
type: 'textarea',
|
||||
hint: '将私钥内容粘贴到此处(支持 PKCS#8 / RSA / EC 格式);也可留空,改在下方填写私钥路径。私钥必须与证书匹配。',
|
||||
placeholder: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----',
|
||||
},
|
||||
{
|
||||
key: 'https.cert_path',
|
||||
label: 'SSL 证书路径',
|
||||
type: 'text',
|
||||
hint: '可选。填写证书文件的绝对路径则优先从文件读取(无需粘贴内容);留空则使用上方粘贴的证书内容。文件被替换后无需改设置,最长 30 秒内自动热加载新证书。',
|
||||
placeholder: '/etc/ssl/mmtl-cert.pem',
|
||||
},
|
||||
{
|
||||
key: 'https.key_path',
|
||||
label: 'SSL 私钥路径',
|
||||
type: 'text',
|
||||
hint: '可选。填写私钥文件的绝对路径则优先从文件读取;留空则使用上方粘贴的私钥内容。',
|
||||
placeholder: '/etc/ssl/mmtl-key.pem',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user