Compare commits

...

43 Commits

Author SHA1 Message Date
github-actions[bot] 0e7dbd6215 chore: bump version to 0.0.29 [skip ci] 2026-08-26 00:43:54 +00:00
truewhile 7fd8de91cb Merge branch 'main' of https://github.com/truewhile/MMTL 2026-08-26 08:43:40 +08:00
truewhile 5f323eb2ce 优化
yo 优化
2026-08-26 08:43:36 +08:00
github-actions[bot] c0ac8bf11a chore: bump version to 0.0.28 [skip ci] 2026-08-25 16:26:45 +00:00
truewhile b676733af7 优化strm同步
优化strm同步
2026-08-26 00:26:28 +08:00
github-actions[bot] 7a2027a3a7 chore: bump version to 0.0.27 [skip ci] 2026-08-25 15:19:28 +00:00
truewhile 9ffb74adce 优化
优化
2026-08-25 23:19:07 +08:00
github-actions[bot] 13faff7078 chore: bump version to 0.0.26 [skip ci] 2026-08-25 14:55:08 +00:00
truewhile a8a4e88d86 Merge branch 'main' of https://github.com/truewhile/MMTL 2026-08-25 22:54:50 +08:00
truewhile 8fa5db88ff 优化
优化
2026-08-25 22:54:46 +08:00
github-actions[bot] 3c325f81c8 chore: bump version to 0.0.25 [skip ci] 2026-08-25 14:12:30 +00:00
truewhile 585434010c 优化
优化
2026-08-25 22:12:05 +08:00
github-actions[bot] eb1a705cae chore: bump version to 0.0.24 [skip ci] 2026-08-25 11:41:17 +00:00
truewhile 10770b2b77 b u g
b u g
2026-08-25 19:41:02 +08:00
github-actions[bot] b61c51e064 chore: bump version to 0.0.23 [skip ci] 2026-08-25 11:19:05 +00:00
truewhile 019ecbec7b 优化
优化
2026-08-25 19:18:49 +08:00
github-actions[bot] 6a96c5640e chore: bump version to 0.0.22 [skip ci] 2026-08-25 11:06:23 +00:00
truewhile 4025e92cb4 Merge branch 'main' of https://github.com/truewhile/MMTL 2026-08-25 19:06:02 +08:00
truewhile 2355419ef9 优化
优化
2026-08-25 19:05:59 +08:00
github-actions[bot] 86214ea796 chore: bump version to 0.0.21 [skip ci] 2026-08-25 08:40:09 +00:00
truewhile 774f2d4695 Merge branch 'main' of https://github.com/truewhile/MMTL 2026-08-25 16:39:51 +08:00
truewhile efa64051ea 优化
优化
2026-08-25 16:39:47 +08:00
github-actions[bot] 5095347ace chore: bump version to 0.0.20 [skip ci] 2026-08-25 07:22:05 +00:00
truewhile ad9260f8fe Merge branch 'main' of https://github.com/truewhile/MMTL 2026-08-25 15:21:46 +08:00
truewhile 4ae2502096 优化字幕
优化字幕
2026-08-25 15:21:43 +08:00
github-actions[bot] c05b5259ef chore: bump version to 0.0.19 [skip ci] 2026-08-25 06:13:18 +00:00
truewhile da1fb02c9d 优化
优化
2026-08-25 14:12:59 +08:00
github-actions[bot] eb09251424 chore: bump version to 0.0.18 [skip ci] 2026-08-25 03:33:45 +00:00
truewhile 5584862352 处理bug
处理bug
2026-08-25 11:30:51 +08:00
github-actions[bot] d5251c1e4e chore: bump version to 0.0.17 [skip ci] 2026-08-24 16:26:50 +00:00
truewhile 96a554b4b5 优化弹幕识别逻辑
优化弹幕识别逻辑
2026-08-25 00:26:32 +08:00
github-actions[bot] 3bd441c288 chore: bump version to 0.0.16 [skip ci] 2026-08-24 15:06:52 +00:00
truewhile c514fb360e 优化
优化
2026-08-24 23:06:35 +08:00
github-actions[bot] c0c1412f4a chore: bump version to 0.0.15 [skip ci] 2026-08-24 14:39:03 +00:00
truewhile 360f9c173b Merge branch 'main' of https://github.com/truewhile/MMTL 2026-08-24 22:38:41 +08:00
truewhile 60b11bc2bc 优化
优化
2026-08-24 22:38:23 +08:00
github-actions[bot] ac546f1197 chore: bump version to 0.0.14 [skip ci] 2026-08-24 14:16:49 +00:00
truewhile 93c7487c96 优化播放体验
优化播放体验
2026-08-24 22:16:31 +08:00
github-actions[bot] 2e8b1cb7a1 chore: bump version to 0.0.13 [skip ci] 2026-08-24 13:25:42 +00:00
truewhile cdb1564826 Merge branch 'main' of https://github.com/truewhile/MMTL 2026-08-24 21:25:27 +08:00
truewhile 90c10137e9 优化
优化
2026-08-24 21:25:24 +08:00
github-actions[bot] 20ceef1fcf chore: bump version to 0.0.12 [skip ci] 2026-08-24 13:06:56 +00:00
truewhile 43fdfe8202 处理无法播放的问题
处理无法播放的问题
2026-08-24 21:06:44 +08:00
80 changed files with 4456 additions and 685 deletions
+1 -1
View File
@@ -1 +1 @@
0.0.11
0.0.29
+9 -23
View File
@@ -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()
+267
View File
@@ -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),
)
}
}
+107
View File
@@ -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

+9 -9
View File
@@ -40,15 +40,15 @@ func TestLoadDefaults(t *testing.T) {
if cfg.Database.MaxOpenConns != defaultDatabaseMaxOpenConns {
t.Fatalf("expected default MaxOpenConns %d, got %d", defaultDatabaseMaxOpenConns, cfg.Database.MaxOpenConns)
}
if cfg.Cache.RedisPrefix != "mmtl" {
t.Fatalf("expected default redis prefix, got %q", cfg.Cache.RedisPrefix)
}
if cfg.Cache.MediaTTLSeconds != 15 {
t.Fatalf("expected default media cache ttl 15, got %d", cfg.Cache.MediaTTLSeconds)
}
if cfg.Search.Index != "mmtl_media" {
t.Fatalf("expected default search index, got %q", cfg.Search.Index)
}
if cfg.Cache.RedisPrefix != "mmtl" {
t.Fatalf("expected default redis prefix, got %q", cfg.Cache.RedisPrefix)
}
if cfg.Cache.MediaTTLSeconds != 15 {
t.Fatalf("expected default media cache ttl 15, got %d", cfg.Cache.MediaTTLSeconds)
}
if cfg.Search.Index != "mmtl_media" {
t.Fatalf("expected default search index, got %q", cfg.Search.Index)
}
if cfg.Database.MaxIdleConns != defaultDatabaseMaxIdleConns {
t.Fatalf("expected default MaxIdleConns %d, got %d", defaultDatabaseMaxIdleConns, cfg.Database.MaxIdleConns)
}
+11
View File
@@ -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.
+82
View File
@@ -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"`
+1 -1
View File
@@ -177,7 +177,7 @@ func (h *ApiConfigHandler) TestApiConfig(c *gin.Context) {
_ = h.svc.ApiConfig.UpdateTestResult(c.Request.Context(), provider, result)
c.JSON(http.StatusOK, gin.H{
"code": 0,
"code": 0,
"message": "ok",
"data": gin.H{
"result": result,
+1 -1
View File
@@ -30,4 +30,4 @@ func getDanmakuConfigHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, svc.Danmaku.Config(c.Request.Context()))
}
}
}
+1 -1
View File
@@ -144,4 +144,4 @@ func TestEmbyLoginWithAuthorizationHeaderToken(t *testing.T) {
if w.Code != http.StatusOK {
t.Fatalf("Authorization-token request = %d body=%s", w.Code, w.Body.String())
}
}
}
@@ -75,7 +75,7 @@ func TestEmbySubtitleOfficialRouteServesRawASS(t *testing.T) {
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos).SetSubtitleService(
service.NewSubtitleService(zap.NewNop(), repos),
service.NewSubtitleService(&config.Config{}, zap.NewNop(), repos),
),
})
@@ -150,7 +150,7 @@ func TestEmbySubtitleDeliveryUrlGetsToken(t *testing.T) {
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos).SetSubtitleService(
service.NewSubtitleService(zap.NewNop(), repos),
service.NewSubtitleService(&config.Config{}, zap.NewNop(), repos),
),
})
+2 -2
View File
@@ -32,7 +32,7 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
return
}
role, _ := c.Get(middleware.CtxUserRole)
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("all") == "1")
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1")
if !includeHidden {
libs = service.FilterDisplayCloudLibraries(c.Request.Context(), svc.Repo, libs)
visibility := mediaVisibilityForRequest(c, svc)
@@ -60,7 +60,7 @@ func getLibraryHandler(svc *service.Container) gin.HandlerFunc {
return
}
role, _ := c.Get(middleware.CtxUserRole)
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("all") == "1")
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1")
if !includeHidden {
libs := service.FilterDisplayCloudLibraries(c.Request.Context(), svc.Repo, []model.Library{*lib})
if len(libs) == 0 || !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, libs[0], mediaVisibilityForRequest(c, svc)) {
+10 -10
View File
@@ -23,16 +23,16 @@ func TestAuthenticatedRouteSurfacesAreRegistered(t *testing.T) {
routes[route.Method+" "+route.Path] = true
}
for _, want := range []string{
"GET /api/me",
"GET /api/auth/permissions",
"GET /api/libraries",
"GET /api/media",
"GET /api/stream/:id",
"GET /api/storage",
"GET /api/watch-history",
"GET /api/playback/:id/info",
} {
for _, want := range []string{
"GET /api/me",
"GET /api/auth/permissions",
"GET /api/libraries",
"GET /api/media",
"GET /api/stream/:id",
"GET /api/storage",
"GET /api/watch-history",
"GET /api/playback/:id/info",
} {
if !routes[want] {
t.Fatalf("%s route is not registered", want)
}
+3 -3
View File
@@ -26,9 +26,9 @@ func statsUserHandler(svc *service.Container) gin.HandlerFunc {
_ = svc.Repo.DB.Model(&model.PlaybackHistory{}).
Where("user_id = ?", uid).Count(&total).Error
c.JSON(http.StatusOK, gin.H{
"user_id": uid,
"watched_ms": watched,
"plays": total,
"user_id": uid,
"watched_ms": watched,
"plays": total,
"watched_hours": float64(watched) / 1000.0 / 3600.0,
})
}
+17 -2
View File
@@ -178,6 +178,7 @@ type strmSyncPathReq struct {
DeleteDir *bool `json:"delete_dir"`
Cron string `json:"cron"`
EnableCron *bool `json:"enable_cron"`
SyncMode string `json:"sync_mode"`
Enabled *bool `json:"enabled"`
}
@@ -261,7 +262,16 @@ func deleteStrmSyncPathHandler(svc *service.Container) gin.HandlerFunc {
func startStrmSyncHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Strm.StartSync(c.Request.Context(), c.Param("id")); err != nil {
mode := c.Query("mode")
if mode == "" {
var body struct {
Mode string `json:"mode"`
}
if err := c.ShouldBindJSON(&body); err == nil && body.Mode != "" {
mode = body.Mode
}
}
if err := svc.Strm.StartSync(c.Request.Context(), c.Param("id"), mode); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -410,7 +420,11 @@ func cancelPendingDownloadsHandler(svc *service.Container) gin.HandlerFunc {
func strmPlayHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
provider := strings.TrimSpace(c.Param("provider"))
result, err := svc.Strm.ResolvePlay(c.Request.Context(), provider, url.Values(c.Request.URL.Query()))
q := url.Values(c.Request.URL.Query())
if userAgent := c.GetHeader("User-Agent"); userAgent != "" {
q.Set("__ua", userAgent)
}
result, err := svc.Strm.ResolvePlay(c.Request.Context(), provider, q)
if err != nil {
if errors.Is(err, service.ErrStrmPlayNotFound) {
c.Status(http.StatusNotFound)
@@ -454,6 +468,7 @@ func strmSyncPathFromReq(req strmSyncPathReq) *model.StrmSyncPath {
DeleteDir: boolValue(req.DeleteDir, false),
Cron: strings.TrimSpace(req.Cron),
EnableCron: boolValue(req.EnableCron, false),
SyncMode: strings.TrimSpace(req.SyncMode),
Enabled: boolValue(req.Enabled, true),
}
}
+10 -10
View File
@@ -58,12 +58,12 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"groups": []gin.H{
{
"key": "general",
"label": "常规",
"items": []gin.H{
{"key": "tmdb.language", "type": "select", "label": "TMDb 元数据语言"},
{"key": "transcode.enabled", "type": "toggle", "label": "启用转码"},
{
"key": "general",
"label": "常规",
"items": []gin.H{
{"key": "tmdb.language", "type": "select", "label": "TMDb 元数据语言"},
{"key": "transcode.enabled", "type": "toggle", "label": "启用转码"},
{"key": "transcode.hw_accel", "type": "select", "label": "硬件编码器"},
{"key": "transcode.hw_enabled", "type": "toggle", "label": "启用硬件加速"},
{"key": "transcode.max_jobs", "type": "number", "label": "最大并发"},
@@ -120,10 +120,10 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc {
{"key": "qbittorrent.password", "type": "text"},
{"key": "qbittorrent.savepath", "type": "text"},
},
},
{
"key": "system-update",
"label": "系统更新",
},
{
"key": "system-update",
"label": "系统更新",
"items": []gin.H{
{"key": "system.update.image", "type": "text", "label": "应用镜像"},
{"key": "system.update.compose_dir", "type": "text", "label": "Docker Compose 安装目录"},
+1 -1
View File
@@ -7,4 +7,4 @@ func finishHTTPTask(task *service.TaskHandle, err error, stage, message string,
return
}
task.Finish(err, service.TaskUpdate{Stage: stage, Message: message, Metrics: metrics, Details: details})
}
}
+8 -8
View File
@@ -42,14 +42,14 @@ func EmbyAuthRequired(secret string) gin.HandlerFunc {
return []byte(secret), nil
})
if err != nil || !parsed.Valid || claims.UserID == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"Code": 40101,
"Message": "Invalid token",
})
c.Abort()
return
}
if err != nil || !parsed.Valid || claims.UserID == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"Code": 40101,
"Message": "Invalid token",
})
c.Abort()
return
}
c.Set(EmbyCtxUserID, claims.UserID)
c.Set(CtxUserID, claims.UserID)
+6 -6
View File
@@ -106,13 +106,13 @@ func TestAuthRequiredSyncsAccessTokenCookieFromBearer(t *testing.T) {
},
})
router := gin.New()
router.Use(AuthRequired(secret))
router.GET("/api/test-auth-cookie", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
router := gin.New()
router.Use(AuthRequired(secret))
router.GET("/api/test-auth-cookie", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
req := httptest.NewRequest(http.MethodGet, "/api/test-auth-cookie", nil)
req := httptest.NewRequest(http.MethodGet, "/api/test-auth-cookie", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
+3 -3
View File
@@ -39,7 +39,7 @@ func PredefinedProviders() []ApiProvider {
{ID: "openai", Name: "OpenAI", Description: "GPT 系列模型", HasAPIKey: true, HasBaseURL: true},
{ID: "deepseek", Name: "DeepSeek", Description: "DeepSeek 大模型", HasAPIKey: true, HasBaseURL: true},
{ID: "siliconflow", Name: "SiliconFlow", Description: "AI 模型聚合 API", HasAPIKey: true, HasBaseURL: true},
{ID: "adult", Name: "Adult / 番号", Description: "JavDB/JavBus 成人内容元数据与 Cookie 凭据", HasAPIKey: true, HasBaseURL: true},
{ID: "metatube", Name: "MetaTube", Description: "MetaTube Server 番号元数据后端服务", HasAPIKey: true, HasBaseURL: true},
}
{ID: "adult", Name: "Adult / 番号", Description: "JavDB/JavBus 成人内容元数据与 Cookie 凭据", HasAPIKey: true, HasBaseURL: true},
{ID: "metatube", Name: "MetaTube", Description: "MetaTube Server 番号元数据后端服务", HasAPIKey: true, HasBaseURL: true},
}
}
+4 -3
View File
@@ -54,7 +54,8 @@ func AllModels() []interface{} {
&StrmAccount{},
&StrmSyncPath{},
&StrmSyncRecord{},
&StrmDownloadTask{},
&StrmUploadTask{},
}
&StrmDownloadTask{},
&StrmUploadTask{},
&StrmDirCache{},
}
}
+37 -37
View File
@@ -16,7 +16,7 @@ type UserPermission struct {
UserID string `gorm:"uniqueIndex;size:36;not null" json:"user_id"`
// 默认开启(6项)- Basic
CanViewDashboard bool `gorm:"default:true" json:"can_view_dashboard"`
CanViewDashboard bool `gorm:"default:true" json:"can_view_dashboard"`
CanPlayMedia bool `gorm:"default:true" json:"can_play_media"`
CanCast bool `gorm:"default:true" json:"can_cast"`
CanExternalPlayer bool `gorm:"default:true" json:"can_external_player"`
@@ -24,18 +24,18 @@ type UserPermission struct {
CanViewHistory bool `gorm:"default:true" json:"can_view_history"`
// 默认关闭(13项)- Advanced
CanEditMedia bool `gorm:"default:false" json:"can_edit_media"`
CanRescrape bool `gorm:"default:false" json:"can_rescrape"`
CanUseAI bool `gorm:"default:false" json:"can_use_ai"`
CanCaptureFrames bool `gorm:"default:false" json:"can_capture_frames"`
CanManageDownloads bool `gorm:"default:false" json:"can_manage_downloads"`
CanManageSubscriptions bool `gorm:"default:false" json:"can_manage_subscriptions"`
CanManageSites bool `gorm:"default:false" json:"can_manage_sites"`
CanUseAIAssistant bool `gorm:"default:false" json:"can_use_ai_assistant"`
CanManageUsers bool `gorm:"default:false" json:"can_manage_users"`
CanManageFiles bool `gorm:"default:false" json:"can_manage_files"`
CanManageStrm bool `gorm:"default:false" json:"can_manage_strm"`
CanAccessSettings bool `gorm:"default:false" json:"can_access_settings"`
CanEditMedia bool `gorm:"default:false" json:"can_edit_media"`
CanRescrape bool `gorm:"default:false" json:"can_rescrape"`
CanUseAI bool `gorm:"default:false" json:"can_use_ai"`
CanCaptureFrames bool `gorm:"default:false" json:"can_capture_frames"`
CanManageDownloads bool `gorm:"default:false" json:"can_manage_downloads"`
CanManageSubscriptions bool `gorm:"default:false" json:"can_manage_subscriptions"`
CanManageSites bool `gorm:"default:false" json:"can_manage_sites"`
CanUseAIAssistant bool `gorm:"default:false" json:"can_use_ai_assistant"`
CanManageUsers bool `gorm:"default:false" json:"can_manage_users"`
CanManageFiles bool `gorm:"default:false" json:"can_manage_files"`
CanManageStrm bool `gorm:"default:false" json:"can_manage_strm"`
CanAccessSettings bool `gorm:"default:false" json:"can_access_settings"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -52,26 +52,26 @@ func (p *UserPermission) BeforeCreate(_ *gorm.DB) error {
// NewDefaultPermission 创建带有默认权限的 UserPermission。
func NewDefaultPermission(userID string) *UserPermission {
return &UserPermission{
ID: uuid.NewString(),
UserID: userID,
CanViewDashboard: true,
CanPlayMedia: true,
CanCast: true,
CanExternalPlayer: true,
CanFavorite: true,
CanViewHistory: true,
CanEditMedia: false,
CanRescrape: false,
CanUseAI: false,
CanCaptureFrames: false,
CanManageDownloads: false,
CanManageSubscriptions: false,
CanManageSites: false,
CanUseAIAssistant: false,
CanManageUsers: false,
CanManageFiles: false,
CanManageStrm: false,
CanAccessSettings: false,
ID: uuid.NewString(),
UserID: userID,
CanViewDashboard: true,
CanPlayMedia: true,
CanCast: true,
CanExternalPlayer: true,
CanFavorite: true,
CanViewHistory: true,
CanEditMedia: false,
CanRescrape: false,
CanUseAI: false,
CanCaptureFrames: false,
CanManageDownloads: false,
CanManageSubscriptions: false,
CanManageSites: false,
CanUseAIAssistant: false,
CanManageUsers: false,
CanManageFiles: false,
CanManageStrm: false,
CanAccessSettings: false,
}
}
@@ -88,13 +88,13 @@ func (p *UserPermission) PermissionMap() map[string]bool {
"can_rescrape": p.CanRescrape,
"can_use_ai": p.CanUseAI,
"can_capture_frames": p.CanCaptureFrames,
"can_manage_downloads": p.CanManageDownloads,
"can_manage_subscriptions": p.CanManageSubscriptions,
"can_manage_downloads": p.CanManageDownloads,
"can_manage_subscriptions": p.CanManageSubscriptions,
"can_manage_sites": p.CanManageSites,
"can_use_ai_assistant": p.CanUseAIAssistant,
"can_use_ai_assistant": p.CanUseAIAssistant,
"can_manage_users": p.CanManageUsers,
"can_manage_files": p.CanManageFiles,
"can_manage_strm": p.CanManageStrm,
"can_access_settings": p.CanAccessSettings,
"can_access_settings": p.CanAccessSettings,
}
}
+17
View File
@@ -48,12 +48,19 @@ type StrmSyncPath struct {
DeleteDir bool `json:"delete_dir"` // 清理多余文件时删除空目录
Cron string `gorm:"size:128" json:"cron"` // 5 段 cron 表达式(可选)
EnableCron bool `json:"enable_cron"` // 是否按 Cron 定时同步
SyncMode string `gorm:"size:32;default:'incremental'" json:"sync_mode"` // 默认同步模式:incremental / full
Enabled bool `gorm:"default:true" json:"enabled"`
LastSyncAt *time.Time `json:"last_sync_at"`
LastSyncStatus string `gorm:"size:16" json:"last_sync_status"` // idle/running/ok/error/canceled
LastSyncMessage string `gorm:"size:1024" json:"last_sync_message"`
}
// STRM 同步类型。
const (
StrmSyncTypeIncremental = "incremental"
StrmSyncTypeFull = "full"
)
// StrmSyncRecord 是一次同步执行的记录。
const (
StrmSyncRecordPending = "pending"
@@ -66,6 +73,7 @@ const (
type StrmSyncRecord struct {
Base
SyncPathID string `gorm:"size:36;index" json:"sync_path_id"`
SyncType string `gorm:"size:32;default:'incremental'" json:"sync_type"` // incremental / full
Status string `gorm:"size:16;index" json:"status"`
Total int64 `json:"total"` // 远端发现的文件总数
NewStrm int64 `json:"new_strm"` // 本次新建/更新的 strm 数
@@ -123,3 +131,12 @@ type StrmUploadTask struct {
StartedAt *time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
}
// StrmDirCache 缓存远端网盘目录 ID 与相对路径映射(支持 115 增量同步秒级寻址)。
type StrmDirCache struct {
Base
SyncPathID string `gorm:"size:36;index:idx_strm_dir_cache,priority:1" json:"sync_path_id"`
DirID string `gorm:"size:128;index:idx_strm_dir_cache,priority:2" json:"dir_id"`
Path string `gorm:"size:1024" json:"path"` // 相对根目录的路径
}
+2
View File
@@ -31,6 +31,7 @@ type Container struct {
StrmSyncRecord *StrmSyncRecordRepository
StrmDownload *StrmDownloadTaskRepository
StrmUpload *StrmUploadTaskRepository
StrmDirCache *StrmDirCacheRepository
}
// New 将每个 repository 连接到单个 *gorm.DB。
@@ -58,5 +59,6 @@ func New(db *gorm.DB) *Container {
StrmSyncRecord: &StrmSyncRecordRepository{db: db},
StrmDownload: &StrmDownloadTaskRepository{db: db},
StrmUpload: &StrmUploadTaskRepository{db: db},
StrmDirCache: &StrmDirCacheRepository{db: db},
}
}
@@ -66,7 +66,6 @@ func (r *StorageConfigRepository) Upsert(ctx context.Context, c *model.StorageCo
}).Error
}
// Delete removes a storage config by ID.
func (r *StorageConfigRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.StorageConfig{}).Error
+304 -140
View File
@@ -3,6 +3,7 @@ package repository
import (
"context"
"errors"
"sync"
"time"
"gorm.io/gorm"
@@ -10,13 +11,17 @@ import (
"github.com/ShukeBta/MMTL/internal/model"
)
var strmClaimMu sync.Mutex
// ─── StrmAccount ───────────────────────────────────────────────────────────────
// StrmAccountRepository persists model.StrmAccount.
type StrmAccountRepository struct{ db *gorm.DB }
func (r *StrmAccountRepository) Create(ctx context.Context, a *model.StrmAccount) error {
return r.db.WithContext(ctx).Create(a).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Create(a).Error
})
}
func (r *StrmAccountRepository) FindByID(ctx context.Context, id string) (*model.StrmAccount, error) {
@@ -38,20 +43,24 @@ func (r *StrmAccountRepository) List(ctx context.Context) ([]model.StrmAccount,
}
func (r *StrmAccountRepository) Update(ctx context.Context, a *model.StrmAccount) error {
return r.db.WithContext(ctx).Model(&model.StrmAccount{}).Where("id = ?", a.ID).Updates(map[string]any{
"name": a.Name,
"provider": a.Provider,
"config": a.Config,
"enabled": a.Enabled,
"last_test_at": a.LastTestAt,
"last_test_result": a.LastTestResult,
"last_test_ok": a.LastTestOK,
"updated_at": time.Now(),
}).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.StrmAccount{}).Where("id = ?", a.ID).Updates(map[string]any{
"name": a.Name,
"provider": a.Provider,
"config": a.Config,
"enabled": a.Enabled,
"last_test_at": a.LastTestAt,
"last_test_result": a.LastTestResult,
"last_test_ok": a.LastTestOK,
"updated_at": time.Now(),
}).Error
})
}
func (r *StrmAccountRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.StrmAccount{}).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.StrmAccount{}).Error
})
}
// ─── StrmSyncPath ──────────────────────────────────────────────────────────────
@@ -60,7 +69,9 @@ func (r *StrmAccountRepository) Delete(ctx context.Context, id string) error {
type StrmSyncPathRepository struct{ db *gorm.DB }
func (r *StrmSyncPathRepository) Create(ctx context.Context, p *model.StrmSyncPath) error {
return r.db.WithContext(ctx).Create(p).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Create(p).Error
})
}
func (r *StrmSyncPathRepository) FindByID(ctx context.Context, id string) (*model.StrmSyncPath, error) {
@@ -82,33 +93,38 @@ func (r *StrmSyncPathRepository) List(ctx context.Context) ([]model.StrmSyncPath
}
func (r *StrmSyncPathRepository) Update(ctx context.Context, p *model.StrmSyncPath) error {
return r.db.WithContext(ctx).Model(&model.StrmSyncPath{}).Where("id = ?", p.ID).Updates(map[string]any{
"name": p.Name,
"account_id": p.AccountID,
"provider": p.Provider,
"remote_path": p.RemotePath,
"local_path": p.LocalPath,
"strm_base_url": p.StrmBaseURL,
"video_ext": p.VideoExt,
"meta_ext": p.MetaExt,
"exclude_name": p.ExcludeName,
"min_video_size_mb": p.MinVideoSizeMB,
"add_path": p.AddPath,
"download_meta": p.DownloadMeta,
"upload_meta": p.UploadMeta,
"delete_dir": p.DeleteDir,
"cron": p.Cron,
"enable_cron": p.EnableCron,
"enabled": p.Enabled,
"last_sync_at": p.LastSyncAt,
"last_sync_status": p.LastSyncStatus,
"last_sync_message": p.LastSyncMessage,
"updated_at": time.Now(),
}).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.StrmSyncPath{}).Where("id = ?", p.ID).Updates(map[string]any{
"name": p.Name,
"account_id": p.AccountID,
"provider": p.Provider,
"remote_path": p.RemotePath,
"local_path": p.LocalPath,
"strm_base_url": p.StrmBaseURL,
"video_ext": p.VideoExt,
"meta_ext": p.MetaExt,
"exclude_name": p.ExcludeName,
"min_video_size_mb": p.MinVideoSizeMB,
"add_path": p.AddPath,
"download_meta": p.DownloadMeta,
"upload_meta": p.UploadMeta,
"delete_dir": p.DeleteDir,
"cron": p.Cron,
"enable_cron": p.EnableCron,
"sync_mode": p.SyncMode,
"enabled": p.Enabled,
"last_sync_at": p.LastSyncAt,
"last_sync_status": p.LastSyncStatus,
"last_sync_message": p.LastSyncMessage,
"updated_at": time.Now(),
}).Error
})
}
func (r *StrmSyncPathRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.StrmSyncPath{}).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.StrmSyncPath{}).Error
})
}
// ─── StrmSyncRecord ────────────────────────────────────────────────────────────
@@ -117,23 +133,28 @@ func (r *StrmSyncPathRepository) Delete(ctx context.Context, id string) error {
type StrmSyncRecordRepository struct{ db *gorm.DB }
func (r *StrmSyncRecordRepository) Create(ctx context.Context, rec *model.StrmSyncRecord) error {
return r.db.WithContext(ctx).Create(rec).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Create(rec).Error
})
}
func (r *StrmSyncRecordRepository) Update(ctx context.Context, rec *model.StrmSyncRecord) error {
return r.db.WithContext(ctx).Model(&model.StrmSyncRecord{}).Where("id = ?", rec.ID).Updates(map[string]any{
"status": rec.Status,
"total": rec.Total,
"new_strm": rec.NewStrm,
"new_meta": rec.NewMeta,
"uploaded": rec.Uploaded,
"pruned": rec.Pruned,
"skipped": rec.Skipped,
"message": rec.Message,
"started_at": rec.StartedAt,
"finished_at": rec.FinishedAt,
"updated_at": time.Now(),
}).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.StrmSyncRecord{}).Where("id = ?", rec.ID).Updates(map[string]any{
"sync_type": rec.SyncType,
"status": rec.Status,
"total": rec.Total,
"new_strm": rec.NewStrm,
"new_meta": rec.NewMeta,
"uploaded": rec.Uploaded,
"pruned": rec.Pruned,
"skipped": rec.Skipped,
"message": rec.Message,
"started_at": rec.StartedAt,
"finished_at": rec.FinishedAt,
"updated_at": time.Now(),
}).Error
})
}
func (r *StrmSyncRecordRepository) List(ctx context.Context, syncPathID string, limit int) ([]model.StrmSyncRecord, error) {
@@ -155,7 +176,21 @@ func (r *StrmSyncRecordRepository) List(ctx context.Context, syncPathID string,
type StrmDownloadTaskRepository struct{ db *gorm.DB }
func (r *StrmDownloadTaskRepository) Create(ctx context.Context, t *model.StrmDownloadTask) error {
return r.db.WithContext(ctx).Create(t).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Create(t).Error
})
}
func (r *StrmDownloadTaskRepository) CreateInBatches(ctx context.Context, tasks []*model.StrmDownloadTask, batchSize int) error {
if len(tasks) == 0 {
return nil
}
if batchSize <= 0 {
batchSize = 100
}
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).CreateInBatches(tasks, batchSize).Error
})
}
func (r *StrmDownloadTaskRepository) FindByID(ctx context.Context, id string) (*model.StrmDownloadTask, error) {
@@ -205,24 +240,29 @@ func (r *StrmDownloadTaskRepository) CountByStatus(ctx context.Context) (map[str
// ClaimPendingDownload picks the oldest pending task and marks it running.
// Returns (nil, nil) when the queue is empty.
func (r *StrmDownloadTaskRepository) ClaimPendingDownload(ctx context.Context, limit int) ([]model.StrmDownloadTask, error) {
strmClaimMu.Lock()
defer strmClaimMu.Unlock()
var rows []model.StrmDownloadTask
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("status = ? AND (next_try_at IS NULL OR next_try_at <= ?)", model.StrmTaskPending, time.Now()).
Order("created_at asc").Limit(limit).Find(&rows).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
ids := make([]string, 0, len(rows))
now := time.Now()
for i := range rows {
ids = append(ids, rows[i].ID)
rows[i].Status = model.StrmTaskRunning
rows[i].StartedAt = &now
}
return tx.Model(&model.StrmDownloadTask{}).Where("id IN ?", ids).
Updates(map[string]any{"status": model.StrmTaskRunning, "started_at": now}).Error
err := withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("status = ? AND (next_try_at IS NULL OR next_try_at <= ?)", model.StrmTaskPending, time.Now()).
Order("created_at asc").Limit(limit).Find(&rows).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
ids := make([]string, 0, len(rows))
now := time.Now()
for i := range rows {
ids = append(ids, rows[i].ID)
rows[i].Status = model.StrmTaskRunning
rows[i].StartedAt = &now
}
return tx.Model(&model.StrmDownloadTask{}).Where("id IN ?", ids).
Updates(map[string]any{"status": model.StrmTaskRunning, "started_at": now}).Error
})
})
if err != nil {
return nil, err
@@ -231,62 +271,86 @@ func (r *StrmDownloadTaskRepository) ClaimPendingDownload(ctx context.Context, l
}
func (r *StrmDownloadTaskRepository) Update(ctx context.Context, t *model.StrmDownloadTask) error {
return r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).Where("id = ?", t.ID).Updates(map[string]any{
"status": t.Status,
"error": t.Error,
"retry_count": t.RetryCount,
"next_try_at": t.NextTryAt,
"started_at": t.StartedAt,
"finished_at": t.FinishedAt,
"updated_at": time.Now(),
}).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).Where("id = ?", t.ID).Updates(map[string]any{
"status": t.Status,
"error": t.Error,
"retry_count": t.RetryCount,
"next_try_at": t.NextTryAt,
"started_at": t.StartedAt,
"finished_at": t.FinishedAt,
"updated_at": time.Now(),
}).Error
})
}
func (r *StrmDownloadTaskRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.StrmDownloadTask{}).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.StrmDownloadTask{}).Error
})
}
// ClearDone 清空全部已完成下载任务。
func (r *StrmDownloadTaskRepository) ClearDone(ctx context.Context) (int64, error) {
res := r.db.WithContext(ctx).Where("status = ?", model.StrmTaskDone).Delete(&model.StrmDownloadTask{})
return res.RowsAffected, res.Error
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Where("status = ?", model.StrmTaskDone).Delete(&model.StrmDownloadTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
// ClearFinished 清空全部已完成与失败下载任务。
func (r *StrmDownloadTaskRepository) ClearFinished(ctx context.Context) (int64, error) {
res := r.db.WithContext(ctx).Where("status IN ?", []string{model.StrmTaskDone, model.StrmTaskFailed}).
Delete(&model.StrmDownloadTask{})
return res.RowsAffected, res.Error
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Where("status IN ?", []string{model.StrmTaskDone, model.StrmTaskFailed}).
Delete(&model.StrmDownloadTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
// RetryAllFailed 把所有失败任务重置回待处理,清空错误与重试计数。
func (r *StrmDownloadTaskRepository) RetryAllFailed(ctx context.Context) (int64, error) {
res := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
Where("status = ?", model.StrmTaskFailed).
Updates(map[string]any{
"status": model.StrmTaskPending,
"error": "",
"retry_count": 0,
"next_try_at": nil,
"started_at": nil,
"finished_at": nil,
"updated_at": time.Now(),
})
return res.RowsAffected, res.Error
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
Where("status = ?", model.StrmTaskFailed).
Updates(map[string]any{
"status": model.StrmTaskPending,
"error": "",
"retry_count": 0,
"next_try_at": nil,
"started_at": nil,
"finished_at": nil,
"updated_at": time.Now(),
})
count = res.RowsAffected
return res.Error
})
return count, err
}
// CancelPending 批量取消所有排队中的任务。
func (r *StrmDownloadTaskRepository) CancelPending(ctx context.Context) (int64, error) {
now := time.Now()
res := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
Where("status = ?", model.StrmTaskPending).
Updates(map[string]any{
"status": model.StrmTaskCanceled,
"error": "已批量取消",
"finished_at": now,
"updated_at": now,
})
return res.RowsAffected, res.Error
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
Where("status = ?", model.StrmTaskPending).
Updates(map[string]any{
"status": model.StrmTaskCanceled,
"error": "已批量取消",
"finished_at": now,
"updated_at": now,
})
count = res.RowsAffected
return res.Error
})
return count, err
}
// CountActive 统计某同步目录下目标仍在排队/进行的任务数(用于去重)。
@@ -299,10 +363,28 @@ func (r *StrmDownloadTaskRepository) CountActive(ctx context.Context, syncPathID
return count
}
// GetActiveLocalPathMap 一次性获取某同步目录下正在排队或执行中的 local_path 集合,供同步时 O(1) 内存去重。
func (r *StrmDownloadTaskRepository) GetActiveLocalPathMap(ctx context.Context, syncPathID string) (map[string]bool, error) {
var paths []string
err := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
Where("sync_path_id = ? AND status IN ?", syncPathID, []string{model.StrmTaskPending, model.StrmTaskRunning}).
Pluck("local_path", &paths).Error
if err != nil {
return nil, err
}
out := make(map[string]bool, len(paths))
for _, p := range paths {
out[p] = true
}
return out, nil
}
func (r *StrmDownloadTaskRepository) DeleteFinishedOlderThan(ctx context.Context, before time.Time) error {
return r.db.WithContext(ctx).Where("status IN ? AND finished_at < ?",
[]string{model.StrmTaskDone, model.StrmTaskFailed, model.StrmTaskCanceled}, before).
Delete(&model.StrmDownloadTask{}).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Where("status IN ? AND finished_at < ?",
[]string{model.StrmTaskDone, model.StrmTaskFailed, model.StrmTaskCanceled}, before).
Delete(&model.StrmDownloadTask{}).Error
})
}
// ─── StrmUploadTask ────────────────────────────────────────────────────────────
@@ -311,7 +393,21 @@ func (r *StrmDownloadTaskRepository) DeleteFinishedOlderThan(ctx context.Context
type StrmUploadTaskRepository struct{ db *gorm.DB }
func (r *StrmUploadTaskRepository) Create(ctx context.Context, t *model.StrmUploadTask) error {
return r.db.WithContext(ctx).Create(t).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Create(t).Error
})
}
func (r *StrmUploadTaskRepository) CreateInBatches(ctx context.Context, tasks []*model.StrmUploadTask, batchSize int) error {
if len(tasks) == 0 {
return nil
}
if batchSize <= 0 {
batchSize = 100
}
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).CreateInBatches(tasks, batchSize).Error
})
}
func (r *StrmUploadTaskRepository) FindByID(ctx context.Context, id string) (*model.StrmUploadTask, error) {
@@ -380,24 +476,29 @@ func (r *StrmUploadTaskRepository) CountByStatus(ctx context.Context) (map[strin
// ClaimPendingUpload picks the oldest pending task and marks it running.
// Returns (nil, nil) when the queue is empty.
func (r *StrmUploadTaskRepository) ClaimPendingUpload(ctx context.Context, limit int) ([]model.StrmUploadTask, error) {
strmClaimMu.Lock()
defer strmClaimMu.Unlock()
var rows []model.StrmUploadTask
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("status = ? AND (next_try_at IS NULL OR next_try_at <= ?)", model.StrmTaskPending, time.Now()).
Order("created_at asc").Limit(limit).Find(&rows).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
ids := make([]string, 0, len(rows))
now := time.Now()
for i := range rows {
ids = append(ids, rows[i].ID)
rows[i].Status = model.StrmTaskRunning
rows[i].StartedAt = &now
}
return tx.Model(&model.StrmUploadTask{}).Where("id IN ?", ids).
Updates(map[string]any{"status": model.StrmTaskRunning, "started_at": now}).Error
err := withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("status = ? AND (next_try_at IS NULL OR next_try_at <= ?)", model.StrmTaskPending, time.Now()).
Order("created_at asc").Limit(limit).Find(&rows).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
ids := make([]string, 0, len(rows))
now := time.Now()
for i := range rows {
ids = append(ids, rows[i].ID)
rows[i].Status = model.StrmTaskRunning
rows[i].StartedAt = &now
}
return tx.Model(&model.StrmUploadTask{}).Where("id IN ?", ids).
Updates(map[string]any{"status": model.StrmTaskRunning, "started_at": now}).Error
})
})
if err != nil {
return nil, err
@@ -406,19 +507,23 @@ func (r *StrmUploadTaskRepository) ClaimPendingUpload(ctx context.Context, limit
}
func (r *StrmUploadTaskRepository) Update(ctx context.Context, t *model.StrmUploadTask) error {
return r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).Where("id = ?", t.ID).Updates(map[string]any{
"status": t.Status,
"error": t.Error,
"retry_count": t.RetryCount,
"next_try_at": t.NextTryAt,
"started_at": t.StartedAt,
"finished_at": t.FinishedAt,
"updated_at": time.Now(),
}).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).Where("id = ?", t.ID).Updates(map[string]any{
"status": t.Status,
"error": t.Error,
"retry_count": t.RetryCount,
"next_try_at": t.NextTryAt,
"started_at": t.StartedAt,
"finished_at": t.FinishedAt,
"updated_at": time.Now(),
}).Error
})
}
func (r *StrmUploadTaskRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.StrmUploadTask{}).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.StrmUploadTask{}).Error
})
}
// CountActive 统计某同步目录下目标仍在排队/进行的任务数(用于去重)。
@@ -431,8 +536,67 @@ func (r *StrmUploadTaskRepository) CountActive(ctx context.Context, syncPathID,
return count
}
func (r *StrmUploadTaskRepository) DeleteFinishedOlderThan(ctx context.Context, before time.Time) error {
return r.db.WithContext(ctx).Where("status IN ? AND finished_at < ?",
[]string{model.StrmTaskDone, model.StrmTaskFailed, model.StrmTaskCanceled}, before).
Delete(&model.StrmUploadTask{}).Error
// GetActiveLocalPathMap 一次性获取某同步目录下正在排队或执行中的 local_path 集合,供同步时 O(1) 内存去重。
func (r *StrmUploadTaskRepository) GetActiveLocalPathMap(ctx context.Context, syncPathID string) (map[string]bool, error) {
var paths []string
err := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
Where("sync_path_id = ? AND status IN ?", syncPathID, []string{model.StrmTaskPending, model.StrmTaskRunning}).
Pluck("local_path", &paths).Error
if err != nil {
return nil, err
}
out := make(map[string]bool, len(paths))
for _, p := range paths {
out[p] = true
}
return out, nil
}
func (r *StrmUploadTaskRepository) DeleteFinishedOlderThan(ctx context.Context, before time.Time) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Where("status IN ? AND finished_at < ?",
[]string{model.StrmTaskDone, model.StrmTaskFailed, model.StrmTaskCanceled}, before).
Delete(&model.StrmUploadTask{}).Error
})
}
// ─── StrmDirCache ─────────────────────────────────────────────────────────────
// StrmDirCacheRepository persists model.StrmDirCache.
type StrmDirCacheRepository struct{ db *gorm.DB }
func (r *StrmDirCacheRepository) ListBySyncPathID(ctx context.Context, syncPathID string) ([]model.StrmDirCache, error) {
var rows []model.StrmDirCache
err := r.db.WithContext(ctx).Where("sync_path_id = ?", syncPathID).Find(&rows).Error
return rows, err
}
func (r *StrmDirCacheRepository) Set(ctx context.Context, syncPathID, dirID, path string) error {
return withSQLiteBusyRetry(ctx, func() error {
var row model.StrmDirCache
err := r.db.WithContext(ctx).Where("sync_path_id = ? AND dir_id = ?", syncPathID, dirID).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
row = model.StrmDirCache{
SyncPathID: syncPathID,
DirID: dirID,
Path: path,
}
return r.db.WithContext(ctx).Create(&row).Error
}
if err != nil {
return err
}
return r.db.WithContext(ctx).Model(&model.StrmDirCache{}).Where("id = ?", row.ID).Updates(map[string]any{
"path": path,
"updated_at": time.Now(),
}).Error
})
}
func (r *StrmDirCacheRepository) DeleteBySyncPathID(ctx context.Context, syncPathID string) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Where("sync_path_id = ?", syncPathID).Delete(&model.StrmDirCache{}).Error
})
}
+5 -4
View File
@@ -37,10 +37,11 @@ var ErrUnsupported = errors.New("unsupported cloud provider")
// FileEntry is one item in a cloud directory listing.
type FileEntry struct {
ID string `json:"id"` // provider-native file id
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
ID string `json:"id"` // provider-native file id
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
MTime int64 `json:"mtime,omitempty"`
// PickCode is 115-specific; other providers use ID directly.
PickCode string `json:"pick_code,omitempty"`
}
+29 -11
View File
@@ -22,6 +22,12 @@ import (
"github.com/ShukeBta/MMTL/internal/service/cloud115"
)
// OpenAPI115Provider 暴露 115 开放平台驱动接口。
type OpenAPI115Provider interface {
Provider
OpenClient() *cloud115.OpenClient
}
// openAPI115Provider 实现 Provider 接口:List 列目录、Resolve 用 pickcode
// 换下载直链(302 offload,无需代理)、Ping 探测根目录。
type openAPI115Provider struct {
@@ -55,15 +61,16 @@ func (p *openAPI115Provider) List(ctx context.Context, dirID string) ([]FileEntr
if err != nil {
return nil, err
}
for _, f := range files {
out = append(out, FileEntry{
ID: f.FileId,
Name: f.FileName,
IsDir: f.Category == cloud115.TypeDir,
Size: f.FileSize,
PickCode: f.PickCode,
})
}
for _, f := range files {
out = append(out, FileEntry{
ID: f.FileId,
Name: f.FileName,
IsDir: f.Category == cloud115.TypeDir,
Size: f.FileSize,
MTime: f.Utime,
PickCode: f.PickCode,
})
}
if len(files) < pageSize {
break
}
@@ -77,11 +84,22 @@ func (p *openAPI115Provider) List(ctx context.Context, dirID string) ([]FileEntr
}
func (p *openAPI115Provider) Resolve(ctx context.Context, fileRef string) (*DirectLink, error) {
url, err := p.c.GetDownloadURL(ctx, fileRef)
return p.ResolveWithUA(ctx, fileRef, "")
}
func (p *openAPI115Provider) ResolveWithUA(ctx context.Context, fileRef, ua string) (*DirectLink, error) {
url, err := p.c.GetDownloadURLWithUA(ctx, fileRef, ua)
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 刷新用)。
+53 -5
View File
@@ -11,6 +11,7 @@ import (
"net/http"
"net/url"
"strings"
"sync"
"time"
)
@@ -21,6 +22,10 @@ type OpenClient struct {
AccessToken string
RefreshTokenStr string
executor *QueueExecutor
// tokenMu 保护令牌刷新:业务请求中途 access_token 失效时自动刷新重试,
// 多 goroutine(同步列表 + 下载队列)并发下只允许一次刷新进行。
tokenMu sync.Mutex
}
// default115HTTPClient 创建带有防 405 重定向保护的 http.Client。
@@ -85,16 +90,21 @@ type RespBase struct {
Errno int `json:"errno"`
Message string `json:"message"`
Error string `json:"error"`
Count int64 `json:"count"`
Data json.RawMessage `json:"data"`
Raw json.RawMessage `json:"-"` // 原始响应体(外层附加字段用)
}
// doJSON 执行 HTTP 请求并解析为统一响应;带 AccessToken(access=true 时)。
func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map[string]string, access bool, retries int) (*RespBase, error) {
func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map[string]string, access bool, retries int, uas ...string) (*RespBase, error) {
executor := c.executor
if executor == nil {
executor = GetGlobalExecutor()
}
ua := ""
if len(uas) > 0 {
ua = uas[0]
}
var lastErr error
for attempt := 0; attempt <= retries; attempt++ {
@@ -103,7 +113,7 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
return nil, err
}
req, err := c.buildRequest(ctx, method, rawURL, form, access)
req, err := c.buildRequestWithUA(ctx, method, rawURL, form, access, ua)
if err != nil {
return nil, err
}
@@ -171,9 +181,18 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
return &base, lastErr
}
// Token 失效不重试
// Token 失效(access_token 过期,如长时间同步中途过期):自动用
// refresh_token 刷新后重试一次。刷新失败或重试后仍失败才返回,
// 避免长时间同步因 token 过期而整体失败。
if isTokenCode(base.Code) {
return &base, nil
if access && c.tryRefreshTokenLocked() {
continue
}
if access {
// 刷新失败(或已刷新仍失败)时返回明确错误
lastErr = NewOpenAPIResponseError(base.Code, base.Errno, base.Message, base.Error, "115: access_token 校验失败且刷新未成功")
}
return &base, lastErr
}
lastErr = NewOpenAPIResponseError(base.Code, base.Errno, base.Message, base.Error, "115 接口调用失败")
@@ -187,6 +206,10 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
}
func (c *OpenClient) buildRequest(ctx context.Context, method, rawURL string, form map[string]string, access bool) (*http.Request, error) {
return c.buildRequestWithUA(ctx, method, rawURL, form, access, "")
}
func (c *OpenClient) buildRequestWithUA(ctx context.Context, method, rawURL string, form map[string]string, access bool, ua string) (*http.Request, error) {
method = strings.ToUpper(method)
var body io.Reader
if method == http.MethodPost && len(form) > 0 {
@@ -211,7 +234,11 @@ func (c *OpenClient) buildRequest(ctx context.Context, method, rawURL string, fo
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", DefaultUA)
targetUA := DefaultUA
if strings.TrimSpace(ua) != "" {
targetUA = strings.TrimSpace(ua)
}
req.Header.Set("User-Agent", targetUA)
if method == http.MethodPost && len(form) > 0 {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
@@ -226,6 +253,27 @@ func (c *OpenClient) doAuthJSON(ctx context.Context, method, rawURL string, form
return c.doJSON(ctx, method, rawURL, form, true, retries)
}
// doAuthJSONWithUA 带自定义 User-Agent 的业务请求(换取直链等防盗链接口使用)。
func (c *OpenClient) doAuthJSONWithUA(ctx context.Context, method, rawURL string, form map[string]string, retries int, ua string) (*RespBase, error) {
return c.doJSON(ctx, method, rawURL, form, true, retries, ua)
}
// tryRefreshTokenLocked 并发安全地刷新 access_token;成功返回 true(调用方
// 应使用内存中的新 token 重试原请求)。refresh_token 已失效时也会清空内存 token。
func (c *OpenClient) tryRefreshTokenLocked() bool {
c.tokenMu.Lock()
defer c.tokenMu.Unlock()
token, err := c.RefreshToken(c.RefreshTokenStr)
if err != nil {
if IsRefreshTokenDead(err) {
c.SetAuthToken("", "")
}
return false
}
c.SetAuthToken(token.AccessToken, token.RefreshToken)
return true
}
// IsThrottleCode 判断是否为限流错误码。
func IsThrottleCode(code int) bool {
return code == RequestMaxLimitCode || code == RequestRateLimitCode
+71 -3
View File
@@ -89,6 +89,36 @@ func (c *OpenClient) GetFsList(ctx context.Context, cid string, offset, limit in
return files, strings.Join(pathStr, "/"), nil
}
// GetFsListFlat 递归扁平化列出 cid 下的所有文件(跨越所有子目录,不包含文件夹节点),并返回文件列表与该树下的总文件数。
// 类似于 QMediaSync 的 115 扁平化批量拉取机制,极大地降低多层级子目录下的 API 请求次数。
func (c *OpenClient) GetFsListFlat(ctx context.Context, cid string, offset, limit int) ([]RemoteFile, int64, error) {
if cid == "" {
cid = "0"
}
if limit <= 0 {
limit = 1150
}
params := map[string]string{
"cid": cid,
"limit": fmt.Sprint(limit),
"offset": fmt.Sprint(offset),
"cur": "0",
"show_dir": "0",
}
resp, err := c.doAuthJSON(ctx, "GET", ProAPIBase+"/open/ufile/files", params, 2)
if err != nil {
return nil, 0, err
}
if !resp.State {
return nil, 0, NewOpenAPIResponseError(resp.Code, resp.Errno, resp.Message, resp.Error, "115 接口调用失败")
}
files, err := openList[RemoteFile](resp.Data)
if err != nil {
return nil, 0, fmt.Errorf("115: 解析文件列表失败:%w", err)
}
return files, resp.Count, nil
}
// GetFsDetailByCid 查询文件(夹)详情。
func (c *OpenClient) GetFsDetailByCid(ctx context.Context, fileId string) (*RemoteFileDetail, error) {
params := map[string]string{"file_id": fileId}
@@ -113,6 +143,38 @@ type RemoteFileDetail struct {
} `json:"paths"`
}
// RelativePath 计算该目录相对于根同步目录(rootCID)的相对路径。
func (d *RemoteFileDetail) RelativePath(rootCID string) string {
if d == nil || len(d.Paths) == 0 {
return ""
}
if rootCID == "" {
rootCID = "0"
}
rootIdx := -1
for i, p := range d.Paths {
if p.FileId == rootCID {
rootIdx = i
break
}
}
var segments []string
start := 0
if rootIdx >= 0 {
start = rootIdx + 1
} else if len(d.Paths) > 0 && (d.Paths[0].FileId == "0" || d.Paths[0].FileId == "") {
start = 1
}
for i := start; i < len(d.Paths); i++ {
name := strings.TrimSpace(d.Paths[i].Name)
if name != "" {
segments = append(segments, name)
}
}
return strings.Join(segments, "/")
}
// ─── 下载直链 ──────────────────────────────────────────────────────────────────
type downloadURLData struct {
@@ -127,11 +189,17 @@ type downloadURLData struct {
// GetDownloadURL 获取下载直链(pickcode)。命中缓存直接返回,
// 避免对同一文件反复换取直链触发 115 风控。
func (c *OpenClient) GetDownloadURL(ctx context.Context, pickCode string) (string, error) {
if cached := GetDownloadURLCache(pickCode); cached != "" {
return c.GetDownloadURLWithUA(ctx, pickCode, "")
}
// GetDownloadURLWithUA 支持按调用方/播放器 User-Agent 换取对应的 115 CDN 直链(用于 115 防盗链白名单校验)。
func (c *OpenClient) GetDownloadURLWithUA(ctx context.Context, pickCode, ua string) (string, error) {
ua = strings.TrimSpace(ua)
if cached := GetDownloadURLCache(pickCode, ua); cached != "" {
return cached, nil
}
params := map[string]string{"pick_code": pickCode}
resp, err := c.doAuthJSON(ctx, "POST", ProAPIBase+"/open/ufile/downurl", params, 1)
resp, err := c.doAuthJSONWithUA(ctx, "POST", ProAPIBase+"/open/ufile/downurl", params, 1, ua)
if err != nil {
return "", err
}
@@ -143,7 +211,7 @@ func (c *OpenClient) GetDownloadURL(ctx context.Context, pickCode string) (strin
if first.URL.URL == "" {
return "", fmt.Errorf("115: 下载地址为空(文件可能未上传完成或已被删除)")
}
SetDownloadURLCache(pickCode, first.URL.URL)
SetDownloadURLCache(pickCode, first.URL.URL, ua)
return first.URL.URL, nil
}
+30 -7
View File
@@ -7,6 +7,7 @@
package cloud115
import (
"strings"
"sync"
"time"
)
@@ -28,17 +29,30 @@ var (
urlCache = map[string]urlCacheEntry{}
)
// urlCacheKey 构造缓存键名(pickCode + UA,实现直链防盗链按客户端 UA 独立缓存)。
func urlCacheKey(pickCode, ua string) string {
if ua == "" {
return pickCode
}
return pickCode + "@" + ua
}
// GetDownloadURLCache 返回未过期的缓存直链;不存在或已过期返回空串。
func GetDownloadURLCache(pickCode string) string {
func GetDownloadURLCache(pickCode string, uas ...string) string {
if pickCode == "" {
return ""
}
ua := ""
if len(uas) > 0 {
ua = uas[0]
}
key := urlCacheKey(pickCode, ua)
urlCacheMu.Lock()
defer urlCacheMu.Unlock()
entry, ok := urlCache[pickCode]
entry, ok := urlCache[key]
if !ok || time.Now().After(entry.expiresAt) {
if ok {
delete(urlCache, pickCode)
delete(urlCache, key)
}
return ""
}
@@ -46,10 +60,14 @@ func GetDownloadURLCache(pickCode string) string {
}
// SetDownloadURLCache 写入直链缓存。
func SetDownloadURLCache(pickCode, url string) {
func SetDownloadURLCache(pickCode string, url string, uas ...string) {
if pickCode == "" || url == "" {
return
}
ua := ""
if len(uas) > 0 {
ua = uas[0]
}
urlCacheMu.Lock()
defer urlCacheMu.Unlock()
if len(urlCache) >= maxCachedURLs {
@@ -60,15 +78,20 @@ func SetDownloadURLCache(pickCode, url string) {
}
}
}
urlCache[pickCode] = urlCacheEntry{url: url, expiresAt: time.Now().Add(downloadURLCacheTTL)}
key := urlCacheKey(pickCode, ua)
urlCache[key] = urlCacheEntry{url: url, expiresAt: time.Now().Add(downloadURLCacheTTL)}
}
// ClearDownloadURLCache 删除指定 pickcode 的缓存(下载得到非 2xx 时调用)。
// ClearDownloadURLCache 删除指定 pickcode 的所有缓存(下载得到非 2xx 时调用)。
func ClearDownloadURLCache(pickCode string) {
if pickCode == "" {
return
}
urlCacheMu.Lock()
defer urlCacheMu.Unlock()
delete(urlCache, pickCode)
for k := range urlCache {
if k == pickCode || strings.HasPrefix(k, pickCode+"@") {
delete(urlCache, k)
}
}
}
+65
View File
@@ -0,0 +1,65 @@
// Package service — 内置 dandanplay 应用凭据(签名认证回退用)。
//
// 弹弹play 开放 API(https://doc.dandanplay.com/open/)要求所有请求
// 携带应用认证。官方推荐客户端应用使用「签名验证模式」:
//
// 请求头 X-AppId + X-Timestamp + X-Signature
// X-Signature = base64(sha256(AppId + Timestamp + Path + AppSecret))
//
// Timestamp 为 UTC 秒级 Unix 时间戳,Path 为不含域名/查询参数的请求路径。
// 该模式里 AppSecret 只存在于服务端本地,网络上传输的只有绑定
// 时间戳与路径的签名,无法重放到其它请求上——这是「隐藏密钥」真正
// 有效的部分。
//
// 为了让用户开箱即用,这里内置了一组项目自用凭据作为回退(在
// DevCenter 申请)。管理员可在「弹幕」设置页填写自己的 AppId /
// AppSecret 覆盖内置凭据。
//
// 混淆说明(重要):开源项目无法真正隐藏随二进制分发的密钥,XOR
// 混淆只能挡住直接扫源码/复制常量这种程度的提取,挡不住反编译内存
// 取证。因此内置凭据只作为回退,不建议过度依赖它承载大流量。
package service
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"strconv"
)
// dandanplayObfuscationKey 是解开内置凭据的 XOR 混淆密钥。它与密文
// 同处一个二进制,仅作提取门槛,不作安全边界。
const dandanplayObfuscationKey = "MMTL-Danmaku#2026!v2"
// 内置回退凭据(XOR 混淆后的 hex 编码)。
const (
danmakuEmbeddedAppIDHex = "352c24755f7c115d0a52"
danmakuEmbeddedAppKeyHex = "74390d751832375917150d4717586578586620652a05043d5e320f0d0b120d38"
)
// danmakuEmbeddedCredentials 解出内置回退凭据。
func danmakuEmbeddedCredentials() (appID, appKey string) {
return xorDecode(danmakuEmbeddedAppIDHex), xorDecode(danmakuEmbeddedAppKeyHex)
}
// xorDecode 用 dandanplayObfuscationKey 逐字节解开 hex 密文。
func xorDecode(hexStr string) string {
raw, err := hex.DecodeString(hexStr)
if err != nil {
return ""
}
key := []byte(dandanplayObfuscationKey)
out := make([]byte, len(raw))
for i, b := range raw {
out[i] = b ^ key[i%len(key)]
}
return string(out)
}
// dandanplaySignature 计算开放 API 请求签名:
// base64(sha256(AppId + Timestamp + Path + AppSecret))。
// path 只含请求路径(不含域名与查询参数、小写、不 URL 编码)。
func dandanplaySignature(appID, appSecret string, ts int64, path string) string {
sum := sha256.Sum256([]byte(appID + strconv.FormatInt(ts, 10) + path + appSecret))
return base64.StdEncoding.EncodeToString(sum[:])
}
@@ -0,0 +1,84 @@
package service
import (
"context"
"encoding/hex"
"testing"
"github.com/stretchr/testify/require"
)
// 内置回退凭据必须能解出有效的 AppId(AppId 非机密),且与密文
// XOR 回环一致(AppKey 以回环校验完整性,避免把明文密钥再抄一遍)。
func TestDanmakuEmbeddedCredentials(t *testing.T) {
appID, appKey := danmakuEmbeddedCredentials()
require.Equal(t, "xap9r8p3g3", appID)
require.NotEmpty(t, appKey)
require.GreaterOrEqual(t, len(appKey), 24)
// 回环:解出的明文再用同一混淆密钥 XOR 后必须还原出原密文,
// 否则说明密文与密钥失配(改了一边忘了另一边)。
require.Equal(t, danmakuEmbeddedAppIDHex, xorEncode(appID, dandanplayObfuscationKey))
require.Equal(t, danmakuEmbeddedAppKeyHex, xorEncode(appKey, dandanplayObfuscationKey))
}
// xorEncode 是 xorDecode 的逆操作(测试辅助,与生产实现同规则)。
func xorEncode(plain, key string) string {
out := make([]byte, len(plain))
for i := range plain {
out[i] = plain[i] ^ key[i%len(key)]
}
return hex.EncodeToString(out)
}
// 签名向量:算法 base64(sha256(AppId+Timestamp+Path+AppSecret)),
// 用固定时间戳与路径交叉验证实现与文档一致(向量由独立脚本生成)。
func TestDandanplaySignatureVectors(t *testing.T) {
appID, appKey := danmakuEmbeddedCredentials()
const ts = int64(1700000000)
vectors := map[string]string{
"/api/v2/comment/25484": "p3OJPfcsm0aFUUXzUTIoKA3vo9fUUtpZRV7/fqX0t0Y=",
"/api/v2/search/episodes": "x9Wr1tPWmeXAT8UeRK2eut9NRofOPsbp5qEl/uqXHC0=",
}
for path, want := range vectors {
require.Equal(t, want, dandanplaySignature(appID, appKey, ts, path), "path=%s", path)
}
}
// 凭据解析:官方域名 + 未配置 → 内置回退;配置了 → 用户凭据优先;
// 只配一半 → 回退内置;第三方源一律不携带凭据。
func TestDanmakuCredentialsSelection(t *testing.T) {
svc := newDanmakuTestService(t)
ctx := context.Background()
official := "https://api.dandanplay.net/api/v2/comment/25484?withRelated=true"
thirdParty := "https://dm.example.com/api/v2/comment/25484"
embedID, embedKey := danmakuEmbeddedCredentials()
// 1) 官方域名、未配置:内置回退。
id, key, ok := svc.danmakuCredentials(ctx, official)
require.True(t, ok)
require.Equal(t, embedID, id)
require.Equal(t, embedKey, key)
// 2) 官方域名、配置完整:用户凭据覆盖内置。
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuAppIDKey, "my-app-id"))
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuAppKeyKey, "my-app-key"))
id, key, ok = svc.danmakuCredentials(ctx, official)
require.True(t, ok)
require.Equal(t, "my-app-id", id)
require.Equal(t, "my-app-key", key)
// 3) 只配一个:视为不完整,回退内置。
require.NoError(t, svc.repo.Setting.Delete(ctx, DanmakuAppKeyKey))
id, key, ok = svc.danmakuCredentials(ctx, official)
require.True(t, ok)
require.Equal(t, embedID, id)
require.Equal(t, embedKey, key)
// 4) 第三方源:即使配了凭据也不发送(内置凭据更不能外泄)。
id, key, ok = svc.danmakuCredentials(ctx, thirdParty)
require.False(t, ok)
require.Empty(t, id)
require.Empty(t, key)
}
+346
View File
@@ -0,0 +1,346 @@
package service
import (
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
"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)
// at a local server for the duration of a test.
func overrideDanmakuOfficialBase(t *testing.T, base string) {
t.Helper()
old := danmakuOfficialBase
danmakuOfficialBase = base
t.Cleanup(func() { danmakuOfficialBase = old })
}
// writeDanmakuTestVideo writes a deterministic <16MB video-ish file and
// returns its path and the expected dandanplay hash (MD5 of the whole file,
// since the file is smaller than the 16MB prefix).
func writeDanmakuTestVideo(t *testing.T, name string) (path, wantHash string) {
t.Helper()
content := bytes.Repeat([]byte("MMTL-danmaku-hash-test-0123456789"), 500)
path = filepath.Join(t.TempDir(), name)
require.NoError(t, os.WriteFile(path, content, 0o644))
sum := md5.Sum(content)
return path, hex.EncodeToString(sum[:])
}
// danmakuOfficialServer serves /api/v2/match (with the given payload) and a
// comment library for the matched episode.
func danmakuOfficialServer(t *testing.T, matchBody, commentBody string, seen *string) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
if seen != nil {
*seen = string(b)
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, matchBody)
})
mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, commentBody)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
// seedDanmakuVideoMedia inserts a media row with a real path (for the hash
// layer) and optional episode number.
func seedDanmakuVideoMedia(t *testing.T, svc *DanmakuService, id, title, path string, size int64, episode int) {
t.Helper()
m := model.Media{Title: title, Path: path, SizeBytes: size, EpisodeNum: episode}
m.ID = id
require.NoError(t, svc.repo.DB.Create(&m).Error)
}
// 第 1 层:本地文件直接算 hash → 官方 /api/v2/match → 命中后拉弹幕。
func TestDanmakuFetchHashMatchLayer(t *testing.T) {
videoPath, wantHash := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
var seen string
official := danmakuOfficialServer(t,
`{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"测试动画","episodeTitle":"第1话"}]}`,
`<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">弹幕Hash命中</d></i>`,
&seen)
overrideDanmakuOfficialBase(t, official.URL)
svc := newDanmakuTestService(t)
ctx := context.Background()
seedDanmakuVideoMedia(t, svc, "mH", "测试动画", videoPath, 32000, 1)
res, err := svc.Fetch(ctx, "mH", "", "")
require.NoError(t, err)
require.True(t, res.Enabled)
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 齐全。
require.Contains(t, seen, `"fileName":"`+url.QueryEscape("测试动画.第01话")+`"`)
require.Contains(t, seen, `"fileHash":"`+wantHash+`"`)
require.Contains(t, seen, `"fileSize":32000`)
require.Contains(t, seen, `"matchMode":"hashAndFileName"`)
}
// 第 1 层拉弹幕:配置了自定义源时优先自定义源,失败才回退官方。
func TestDanmakuFetchHashMatchUsesConfiguredSourceFirst(t *testing.T) {
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
cfgSrv := newDanmakuSourceServer(t) // /api/v2/comment/25484 → 弹幕A
official := danmakuOfficialServer(t,
`{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"测试动画"}]}`,
`<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">弹幕B官方</d></i>`,
nil)
overrideDanmakuOfficialBase(t, official.URL)
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL()))
seedDanmakuVideoMedia(t, svc, "mC", "测试动画", videoPath, 32000, 0)
res, err := svc.Fetch(ctx, "mC", "", "")
require.NoError(t, err)
// 配置源优先:弹幕来自自定义源而非官方。
require.Contains(t, res.Raw, "弹幕A")
require.NotContains(t, res.Raw, "弹幕B官方")
}
func TestDanmakuFetchHashMatchConfiguredFailsFallsBackOfficial(t *testing.T) {
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
// 配置源:搜索正常,但弹幕接口 500。
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":1001,"animeTitle":"测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话"}]}]}`)
})
mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
cfgSrv := httptest.NewServer(mux)
t.Cleanup(cfgSrv.Close)
official := danmakuOfficialServer(t,
`{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"测试动画"}]}`,
`<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">弹幕官方兜底</d></i>`,
nil)
overrideDanmakuOfficialBase(t, official.URL)
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
seedDanmakuVideoMedia(t, svc, "mF", "测试动画", videoPath, 32000, 0)
res, err := svc.Fetch(ctx, "mF", "", "")
require.NoError(t, err)
require.Contains(t, res.Raw, "弹幕官方兜底")
}
// 第 1 层未命中(matches 为空)→ 第 2 层按文件名+集数搜索。
func TestDanmakuFetchHashMissFallsBackToFileNameSearch(t *testing.T) {
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
cfgSrv := newDanmakuSourceServer(t) // 搜索 + 弹幕A
official := danmakuOfficialServer(t,
`{"success":true,"isMatched":false,"matches":[]}`,
`<i></i>`, nil)
overrideDanmakuOfficialBase(t, official.URL)
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL()))
seedDanmakuVideoMedia(t, svc, "mM", "刮削标题", videoPath, 32000, 1)
res, err := svc.Fetch(ctx, "mM", "", "")
require.NoError(t, err)
require.True(t, res.Enabled)
require.Contains(t, res.Raw, "弹幕A")
// 第 2 层命中:搜索请求按文件名进行。
require.Contains(t, cfgSrv.lastSearch, "anime=")
}
// 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, 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)
}))
t.Cleanup(rangeSrv.Close)
var seen string
official := danmakuOfficialServer(t,
`{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"远程动画"}]}`,
`<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">弹幕Strm命中</d></i>`,
&seen)
overrideDanmakuOfficialBase(t, official.URL)
svc := newDanmakuTestService(t)
var gotProvider string
svc.SetStrmResolver(func(_ context.Context, provider string, _ url.Values) (*StrmPlayResult, error) {
gotProvider = provider
// 播放链路 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")
require.NoError(t, os.WriteFile(strmPath, []byte("http://example.invalid/api/strm/play/115/video.mkv?acct=1&pickcode=abc\n"), 0o644))
seedDanmakuVideoMedia(t, svc, "mS", "远程动画", strmPath, 64, 0)
// STRMURL 需要显式写回(扫库时才解析)。
var media model.Media
require.NoError(t, svc.repo.DB.First(&media, "id = ?", "mS").Error)
media.STRMURL = "/api/strm/play/115/video.mkv?acct=1&pickcode=abc"
require.NoError(t, svc.repo.DB.Save(&media).Error)
res, err := svc.Fetch(ctx, "mS", "", "")
require.NoError(t, err)
require.True(t, res.Enabled)
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。
require.Contains(t, seen, `"fileSize":0`)
}
// match 接口 fileName 语义:去扩展名;strm 文件名含视频扩展名时剥两层。
func TestDanmakuMatchFileName(t *testing.T) {
cases := []struct{ in, want string }{
{"/lib/某番剧.第01话.mkv", "某番剧.第01话"},
{"/lib/某番剧.第01话.strm", "某番剧.第01话"},
{"/lib/movie.mkv.strm", "movie"},
{"plain", "plain"},
}
for _, c := range cases {
require.Equal(t, c.want, danmakuMatchFileName(c.in), "path=%s", c.in)
}
}
// hashLocalFile:本地视频直接读盘算前 16MB MD5,且第二次走缓存。
func TestDanmakuHashLocalFile(t *testing.T) {
videoPath, wantHash := writeDanmakuTestVideo(t, "hashme.mkv")
svc := newDanmakuTestService(t)
got, ok := svc.hashLocalFile(videoPath)
require.True(t, ok)
require.Equal(t, wantHash, got)
got2, ok := svc.hashLocalFile(videoPath)
require.True(t, ok)
require.Equal(t, wantHash, got2)
missing, ok := svc.hashLocalFile(filepath.Join(t.TempDir(), "nope.mkv"))
require.False(t, ok)
require.Empty(t, missing)
}
// 配置源与官方同源时,回退不重复请求同一台服务器(bases 只含一份)。
func TestDanmakuSameBase(t *testing.T) {
require.True(t, sameDanmakuBase("https://api.dandanplay.net", "https://api.dandanplay.net"))
require.False(t, sameDanmakuBase("https://api.dandanplay.net", "https://dm.example.com"))
require.False(t, sameDanmakuBase("", "https://api.dandanplay.net"))
}
// fetchCommentWithFallback:配置源与官方同源时不重复请求;
// 全失败时带出最后一跳错误。
func TestDanmakuFetchCommentWithFallback(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(srv.Close)
svc := newDanmakuTestService(t)
ctx := context.Background()
raw, st, err := svc.fetchCommentWithFallback(ctx, srv.URL, srv.URL, "25484")
require.Error(t, err)
require.Empty(t, raw)
require.Equal(t, "auto", st)
}
// 视频即便能命中 Hash 自动识别,当用户传入手动搜索关键词时应跳过 Hash 匹配,走关键词搜索。
func TestDanmakuFetchHashMatchSkippedOnManualKeyword(t *testing.T) {
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
// 官方服务同时提供 match 和 search:
// match 会返回 episodeId=25484(动画A)
// search 会根据关键词返回 episodeId=99999(动画B)
mux := http.NewServeMux()
var matchCalled bool
mux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
matchCalled = true
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"自动识别动画A","episodeTitle":"第1话"}]}`)
})
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":2002,"animeTitle":"手动搜索动画B","episodes":[{"episodeId":99999,"episodeTitle":"第1话"}]}]}`)
})
mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">自动识别弹幕</d></i>`)
})
mux.HandleFunc("/api/v2/comment/99999", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user2">手动搜索弹幕</d></i>`)
})
official := httptest.NewServer(mux)
t.Cleanup(official.Close)
overrideDanmakuOfficialBase(t, official.URL)
svc := newDanmakuTestService(t)
ctx := context.Background()
seedDanmakuVideoMedia(t, svc, "mManual", "自动识别动画A", videoPath, 32000, 1)
// 1) 默认自动识别:命中 Hash 识别
resAuto, err := svc.Fetch(ctx, "mManual", "", "")
require.NoError(t, err)
require.True(t, matchCalled)
require.Equal(t, "hash", resAuto.MatchMode)
require.Equal(t, int64(25484), resAuto.EpisodeID)
require.Contains(t, resAuto.Raw, "自动识别弹幕")
// 2) 用户传入手动搜索关键词:跳过 Hash 识别,命中搜索结果动画B
resManual, err := svc.Fetch(ctx, "mManual", "手动搜索动画B", "")
require.NoError(t, err)
require.Equal(t, "search", resManual.MatchMode)
require.Equal(t, int64(99999), resManual.EpisodeID)
require.Equal(t, "手动搜索动画B", resManual.AnimeTitle)
require.Contains(t, resManual.Raw, "手动搜索弹幕")
}
+485 -47
View File
@@ -1,20 +1,26 @@
package service
import (
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
)
@@ -27,6 +33,11 @@ const (
DanmakuOpacityKey = "danmaku.opacity"
DanmakuFontSizeKey = "danmaku.font_size"
DanmakuAreaKey = "danmaku.area"
// DanmakuAppIDKey / DanmakuAppKeyKey hold the optional dandanplay
// DevCenter application credentials. When both are set they override the
// built-in fallback pair (see danmaku_credentials.go).
DanmakuAppIDKey = "danmaku.app_id"
DanmakuAppKeyKey = "danmaku.app_key"
)
// DanmakuDefaultSource is the official dandanplay endpoint used when the
@@ -34,6 +45,11 @@ const (
// dandanplay protocol (search/episodes + comment/{episodeId}) may be used.
const DanmakuDefaultSource = "https://api.dandanplay.net"
// danmakuOfficialBase is where identification (/api/v2/match) and the
// comment/search fallback always go, regardless of the configured source.
// A package var (not a const) so tests can point it at a local server.
var danmakuOfficialBase = DanmakuDefaultSource
// DanmakuRenderConfig carries the renderer knobs to the web player.
type DanmakuRenderConfig struct {
Enabled bool `json:"enabled"`
@@ -51,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
@@ -73,12 +95,21 @@ type DanmakuEpisode struct {
}
// DanmakuService fetches danmaku for a media item through the dandanplay
// protocol: search for an episode id by the video's name, then fetch the
// comment library XML. The React player parses and renders it.
// protocol: match by 16MB-prefix hash, then search for an episode id by the
// video's name, then fetch the comment library XML. The React player parses
// and renders it.
type DanmakuService struct {
log *zap.Logger
repo *repository.Container
client *http.Client
// strmResolve resolves a .strm play indirection into a fetchable target
// (local path / redirect URL / proxied link). Wired by the builder to
// StrmService.ResolvePlay; nil means strm sources are skipped.
strmResolve func(ctx context.Context, provider string, q url.Values) (*StrmPlayResult, error)
hashCacheMu sync.Mutex
hashCache map[string]string // stamp → 16MB-prefix MD5
}
func danmakuHTTPClient() *http.Client {
@@ -89,7 +120,20 @@ func NewDanmakuService(log *zap.Logger, repo *repository.Container) *DanmakuServ
if log == nil {
log = zap.NewNop()
}
return &DanmakuService{log: log, repo: repo, client: danmakuHTTPClient()}
return &DanmakuService{
log: log,
repo: repo,
client: danmakuHTTPClient(),
hashCache: make(map[string]string),
}
}
// SetStrmResolver wires the strm play resolver used to fetch cloud video
// bytes for hash computation.
func (s *DanmakuService) SetStrmResolver(resolve func(ctx context.Context, provider string, q url.Values) (*StrmPlayResult, error)) {
if s != nil {
s.strmResolve = resolve
}
}
// Config reads danmaku settings from the runtime settings table.
@@ -121,14 +165,20 @@ func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
// media-derived search term (empty = use the video's own name); pass it from
// the player when the user searches for a custom title. episodeID forces a
// specific danmaku library chosen by the user (from a previous disambiguation
// response); empty means auto-resolution. The danmaku library is resolved
// through the dandanplay protocol:
// response); empty means auto-resolution through the dandanplay protocol:
//
// 1. search episodes by title (+ season/episode number)
// 2a. exactly one hit → fetch that episode's comment library
// 2b. several hits → return candidates (Raw empty) so the player asks the user
// 2c. explicit episodeID → fetch it directly
// 3. fetch the comment library XML
// 1. match: MD5 of the first 16MB of the video (local file read directly,
// .strm resolved to a direct link and range-fetched) → /api/v2/match
// against the official endpoint, which yields the episode library id.
// 2. search by the playing file's name + episode number.
// 3. current auto-identification (original name → title → file name + episode,
// single hit used, several hits returned as candidates for the player).
// 4. manual: the player picks from the returned candidates (episodeID /
// keyword override).
//
// Comments are always fetched from the configured source first (when set)
// and fall back to the official endpoint on failure; identification itself
// always goes to the official endpoint.
//
// When danmaku is disabled the result carries Enabled=false so the player can
// silently skip rendering.
@@ -137,20 +187,29 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
if !res.Enabled {
return res, nil
}
configured := strings.TrimRight(strings.TrimSpace(res.Source), "/")
official := danmakuOfficialBase
base := strings.TrimRight(strings.TrimSpace(res.Source), "/")
if base == "" {
base = DanmakuDefaultSource
}
// 手动指定弹幕库:跳过识别,直接拉取该库(自定义源失败回退官方)。
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
if id, parseErr := strconv.ParseInt(target, 10, 64); parseErr == nil {
res.EpisodeID = id
}
res.MatchMode = "manual"
return res, nil
}
target := strings.TrimSpace(episodeID)
if target == "" {
// 名称与集数优先来自媒体(original_name → title → 文件名),
// 手动搜索关键词时仍沿用当前媒体的集数(同一部番剧同名搜索)。
term, err := s.searchTerms(ctx, mediaID)
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
}
@@ -158,29 +217,70 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
return res, nil
}
candidates, err := s.searchCandidates(ctx, base, 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
}
// 多结果歧义:把候选交回播放器让用户选择(disambiguation)。
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)
}
target := ""
raw, err := s.fetchBody(ctx, fmt.Sprintf("%s/api/v2/comment/%s?withRelated=true", base, target), true)
// 1) hash 识别:始终走官方 /api/v2/match(keyword 手动覆盖时跳过,直接走第 3 层)。
if target == "" && !manualKeyword && 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"
}
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 = raw
res.SourceType = detectDanmakuSourceType(raw)
res.Raw, res.SourceType = raw, st
return res, nil
}
@@ -207,19 +307,19 @@ type danmakuSearchTerms struct {
}
// searchTerms resolves the name and episode number used to look up the
// dandanplay library. Episode 0 (movies / unknown) is left empty so the
// search does not filter by episode.
func (s *DanmakuService) searchTerms(ctx context.Context, mediaID string) (danmakuSearchTerms, error) {
// dandanplay library (and the media row for hash identification). Episode 0
// (movies / unknown) is left empty so the search does not filter by episode.
func (s *DanmakuService) searchTerms(ctx context.Context, mediaID string) (danmakuSearchTerms, *model.Media, error) {
var term danmakuSearchTerms
if s == nil || s.repo == nil || s.repo.Media == nil {
return term, errors.New("media repository unavailable")
return term, nil, errors.New("media repository unavailable")
}
m, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return term, err
return term, nil, err
}
if m == nil {
return term, errors.New("media not found")
return term, nil, errors.New("media not found")
}
if name := strings.TrimSpace(m.OriginalName); name != "" {
term.name = name
@@ -231,7 +331,7 @@ func (s *DanmakuService) searchTerms(ctx context.Context, mediaID string) (danma
if m.EpisodeNum > 0 {
term.episode = strconv.Itoa(m.EpisodeNum)
}
return term, nil
return term, m, nil
}
// searchCandidates returns every anime hit for a name via the dandanplay
@@ -298,6 +398,17 @@ func (s *DanmakuService) fetchBody(ctx context.Context, sourceURL string, follow
}
req.Header.Set("User-Agent", "MMTL/danmaku (+https://github.com/ShukeBta/MMTL)")
req.Header.Set("Accept", "application/json, application/xml, */*")
if appID, appKey, ok := s.danmakuCredentials(ctx, sourceURL); ok {
// 签名认证:base64(sha256(AppId+Timestamp+Path+Secret)),密钥不出服务器。
ts := time.Now().Unix()
path := "/"
if u, err := url.Parse(sourceURL); err == nil && u.Path != "" {
path = u.Path
}
req.Header.Set("X-AppId", appID)
req.Header.Set("X-Timestamp", strconv.FormatInt(ts, 10))
req.Header.Set("X-Signature", dandanplaySignature(appID, appKey, ts, path))
}
resp, err := client.Do(req)
if err != nil {
return "", err
@@ -312,3 +423,330 @@ func (s *DanmakuService) fetchBody(ctx context.Context, sourceURL string, follow
}
return string(body), nil
}
// danmakuCredentials resolves the application credentials for the official
// dandanplay API. Admin-configured values (danmaku.app_id / danmaku.app_key)
// win; otherwise the built-in obfuscated fallback pair is used. Returns
// ok=false for any other host so credentials — including the built-in pair —
// are never sent to third-party dandanplay protocol mirrors. The "official"
// host follows danmakuOfficialBase (overridable in tests).
func (s *DanmakuService) danmakuCredentials(ctx context.Context, sourceURL string) (appID, appKey string, ok bool) {
u, err := url.Parse(sourceURL)
if err != nil {
return "", "", false
}
official, err := url.Parse(danmakuOfficialBase)
if err != nil || !strings.EqualFold(u.Hostname(), official.Hostname()) {
return "", "", false
}
var id, key string
if s != nil && s.repo != nil && s.repo.Setting != nil {
id, _ = s.repo.Setting.Get(ctx, DanmakuAppIDKey)
key, _ = s.repo.Setting.Get(ctx, DanmakuAppKeyKey)
}
id, key = strings.TrimSpace(id), strings.TrimSpace(key)
if id != "" && key != "" {
return id, key, true
}
if id != "" || key != "" {
s.log.Warn("danmaku credentials incomplete, using built-in fallback",
zap.Bool("has_app_id", id != ""), zap.Bool("has_app_key", key != ""))
}
embedID, embedKey := danmakuEmbeddedCredentials()
return embedID, embedKey, true
}
// danmakuHashPrefixBytes 是 dandanplay match 规格要求的前 16MB 数据。
const danmakuHashPrefixBytes = 16 << 20
const danmakuHashCacheMax = 256
func (s *DanmakuService) hashCacheGet(stamp string) (string, bool) {
s.hashCacheMu.Lock()
defer s.hashCacheMu.Unlock()
h, ok := s.hashCache[stamp]
return h, ok
}
func (s *DanmakuService) hashCachePut(stamp, hash string) {
s.hashCacheMu.Lock()
defer s.hashCacheMu.Unlock()
if len(s.hashCache) >= danmakuHashCacheMax {
// 简单淘汰:满了整体清空;哈希只用于重复播放时的缓存命中。
s.hashCache = make(map[string]string)
}
s.hashCache[stamp] = hash
}
// danmakuMatchFileName derives the /api/v2/match fileName: base name without
// the final extension. .strm items are covered too — MMTL strm files drop the
// video extension ("xxx.strm") while pre-existing ones may keep it
// ("xxx.mkv.strm") — so a second strip removes a real video extension only
// (filepath.Ext would misread names like "xxx.第01话" as having an extension).
func danmakuMatchFileName(path string) string {
base := filepath.Base(path)
if ext := filepath.Ext(base); ext != "" {
base = strings.TrimSuffix(base, ext)
}
if second := strings.ToLower(filepath.Ext(base)); second != "" {
if _, ok := videoExtensions[second]; ok && second != ".strm" {
base = strings.TrimSuffix(base, filepath.Ext(base))
}
}
return base
}
// mediaHash returns the dandanplay match hash (MD5 of the first 16MB of the
// video). Local videos are hashed straight from disk; .strm indirections are
// resolved (local path / direct link) and only the 16MB prefix is downloaded.
func (s *DanmakuService) mediaHash(ctx context.Context, media *model.Media) (string, bool) {
if media == nil || media.Path == "" {
return "", false
}
if strings.EqualFold(filepath.Ext(media.Path), ".strm") {
target := media.STRMURL
if target == "" {
parsed, err := readLocalSTRMTarget(media.Path)
if err != nil || parsed == "" {
return "", false
}
target = parsed
}
return s.hashStrmTarget(ctx, target)
}
return s.hashLocalFile(media.Path)
}
// hashLocalFile computes the MD5 of the first 16MB of a local video, cached
// by path+size+mtime so repeated danmaku loads skip the disk read.
func (s *DanmakuService) hashLocalFile(path string) (string, bool) {
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return "", false
}
stamp := fmt.Sprintf("f|%s|%d|%d", path, info.Size(), info.ModTime().UnixNano())
if h, ok := s.hashCacheGet(stamp); ok {
return h, true
}
f, err := os.Open(path) // #nosec G304 -- path 来自已入库的媒体行
if err != nil {
return "", false
}
defer f.Close()
h := md5.New()
if _, err := io.Copy(h, io.LimitReader(f, danmakuHashPrefixBytes)); err != nil {
return "", false
}
hash := hex.EncodeToString(h.Sum(nil))
s.hashCachePut(stamp, hash)
return hash, true
}
// hashStrmTarget computes the video hash behind a .strm indirection:
// MMTL-internal /api/strm/play URLs are resolved through strmResolve (local
// path read directly, cloud links range-fetched); plain http(s) links are
// fetched directly. Only the 16MB prefix is ever downloaded.
func (s *DanmakuService) hashStrmTarget(ctx context.Context, raw string) (string, bool) {
if h, ok := s.hashCacheGet("s|" + raw); ok {
return h, true
}
u, err := url.Parse(raw)
if err != nil {
return "", false
}
var (
src *StrmPlayResult
body io.ReadCloser
)
switch {
case strings.HasPrefix(u.Path, "/api/strm/play/"):
// /api/strm/play/{provider}/video{ext}?acct=..&pickcode=..
segs := strings.Split(strings.TrimPrefix(u.Path, "/api/strm/play/"), "/")
if len(segs) < 2 || s.strmResolve == nil {
return "", false
}
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":
src = &StrmPlayResult{RedirectURL: raw}
default:
// webdav/alist 等协议无法直接用标准 HTTP 拉取,交给搜索层兜底。
return "", false
}
switch {
case src.LocalPath != "":
return s.hashLocalFile(src.LocalPath)
case src.RedirectURL != "":
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()
h := md5.New()
if _, err := io.Copy(h, io.LimitReader(body, danmakuHashPrefixBytes)); err != nil {
return "", false
}
hash := hex.EncodeToString(h.Sum(nil))
s.hashCachePut("s|"+raw, hash)
return hash, true
}
// openRangeBody issues a Range request for the 16MB video prefix. Range is a
// suggestion — servers that ignore it are capped by the caller's LimitReader.
func (s *DanmakuService) openRangeBody(ctx context.Context, target string, headers map[string]string) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "MMTL/danmaku (+https://github.com/ShukeBta/MMTL)")
req.Header.Set("Range", fmt.Sprintf("bytes=0-%d", danmakuHashPrefixBytes-1))
for k, v := range headers {
req.Header.Set(k, v)
}
client := s.client
if client == nil {
client = danmakuHTTPClient()
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
resp.Body.Close()
return nil, fmt.Errorf("hash range fetch returned HTTP %d", resp.StatusCode)
}
return resp.Body, nil
}
// danmakuMatch mirrors one hit of the /api/v2/match response.
type danmakuMatch struct {
EpisodeID int64 `json:"episodeId"`
AnimeID int64 `json:"animeId"`
AnimeTitle string `json:"animeTitle"`
EpisodeTitle string `json:"episodeTitle"`
}
// matchOfficial identifies the video via POST /api/v2/match on the official
// endpoint (always official, signed with the app credentials). Returns the
// candidate list; empty means nothing matched.
//
// fileName must be URL-escaped: the official API rejects raw non-ASCII file
// names with errorCode 2 (verified against the live API — QueryEscape's
// percent-encoding with "+" for space is accepted).
func (s *DanmakuService) matchOfficial(ctx context.Context, fileName, fileHash string, fileSize int64, durationSec int) ([]danmakuMatch, error) {
appID, appKey, ok := s.danmakuCredentials(ctx, danmakuOfficialBase)
if !ok {
return nil, errors.New("danmaku credentials unavailable")
}
payload := struct {
FileName string `json:"fileName"`
FileHash string `json:"fileHash"`
FileSize int64 `json:"fileSize"`
VideoDuration int `json:"videoDuration"`
MatchMode string `json:"matchMode"`
}{
FileName: url.QueryEscape(fileName),
FileHash: fileHash,
FileSize: fileSize,
VideoDuration: durationSec,
MatchMode: "hashAndFileName",
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
path := "/api/v2/match"
ts := time.Now().Unix()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, danmakuOfficialBase+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-AppId", appID)
req.Header.Set("X-Timestamp", strconv.FormatInt(ts, 10))
req.Header.Set("X-Signature", dandanplaySignature(appID, appKey, ts, path))
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("danmaku match returned HTTP %d", resp.StatusCode)
}
var out struct {
Success bool `json:"success"`
Matches []danmakuMatch `json:"matches"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("danmaku match returned invalid JSON: %w", err)
}
if !out.Success {
return nil, nil
}
return out.Matches, nil
}
// sameDanmakuBase reports whether two source bases point at the same origin
// (host, including port) so the fallback does not call the same server twice.
func sameDanmakuBase(a, b string) bool {
ua, errA := url.Parse(a)
ub, errB := url.Parse(b)
return errA == nil && errB == nil && strings.EqualFold(ua.Host, ub.Host)
}
// fetchCommentWithFallback fetches a comment library from the configured
// source first (when set and different from official), falling back to the
// official endpoint on failure.
func (s *DanmakuService) fetchCommentWithFallback(ctx context.Context, configured, official, target string) (raw, sourceType string, err error) {
var bases []string
if configured != "" && !sameDanmakuBase(configured, official) {
bases = append(bases, configured)
}
bases = append(bases, official)
var lastErr error
for i, base := range bases {
raw, err = s.fetchBody(ctx, fmt.Sprintf("%s/api/v2/comment/%s?withRelated=true", base, target), true)
if err == nil {
return raw, detectDanmakuSourceType(raw), nil
}
lastErr = err
if i < len(bases)-1 {
s.log.Warn("danmaku comment fetch failed on configured source, falling back to official", zap.String("source", base), zap.Error(err))
}
}
return "", "auto", lastErr
}
// searchCandidatesWithFallback searches the configured source first (when
// set and different from official), falling back to the official endpoint on
// failure.
func (s *DanmakuService) searchCandidatesWithFallback(ctx context.Context, configured, official, name, episode string) ([]DanmakuAnime, error) {
if configured != "" && !sameDanmakuBase(configured, official) {
candidates, err := s.searchCandidates(ctx, configured, name, episode)
if err == nil {
return candidates, nil
}
s.log.Warn("danmaku search failed on configured source, falling back to official", zap.String("source", configured), zap.Error(err))
}
return s.searchCandidates(ctx, official, name, episode)
}
+13
View File
@@ -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) {
@@ -205,6 +209,13 @@ func TestDanmakuFetchHandlesSearch404(t *testing.T) {
srv := httptest.NewServer(mux)
defer srv.Close()
// 配置源 404 会回退官方,官方同样 404 才能稳定复现错误。
official := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer official.Close()
overrideDanmakuOfficialBase(t, official.URL)
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL))
@@ -255,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 {
+1
View File
@@ -111,6 +111,7 @@ const (
var (
embySeasonDirRE = regexp.MustCompile(`(?i)^(season[\s._-]*\d+|s\d+|specials?|sp|ova|oad|extra|extras|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典)$`)
embySeasonSuffixRE = regexp.MustCompile(`(?i)(?:[\s._-]+(?:season[\s._-]*\d+|s\d+|第\s*[0-9一二三四五六七八九十百零两]+\s*季|specials?|sp|ova|oad|extra|extras|特别篇|特別篇|番外|特典)|\s*第\s*[0-9一二三四五六七八九十百零两]+\s*季)\s*$`)
embyYearSuffixRE = regexp.MustCompile(`\s*[\((\[]\d{4}[\))\]]\s*$`)
embyEpisodeTitleRE = regexp.MustCompile(`(?i)\s*[-_ ]*s\d{1,2}e\d{1,3}.*$`)
)
@@ -334,3 +334,105 @@ func TestEmbyCloudAnimeUsesSeriesNameFromChineseSeasonFolder(t *testing.T) {
t.Fatalf("cloud anime should be grouped as one series named 剑来, got %#v", items)
}
}
func TestEmbySeriesGroupingWithPrefixedSeasonFolders(t *testing.T) {
svc := newTestEmbyService(t)
lib := model.Library{Name: "动漫", Path: `/media/动漫`, Type: "anime", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
for season := 1; season <= 5; season++ {
for ep := 1; ep <= 3; ep++ {
media := model.Media{
Base: model.Base{ID: fmt.Sprintf("shokugeki-s%02de%02d", season, ep)},
LibraryID: lib.ID,
Title: "食戟之灵",
OriginalName: "食戟のソーマ",
ScrapeStatus: "matched",
TMDbID: 62273,
BangumiID: 116461,
Path: fmt.Sprintf(`/media/动漫/食戟之灵/食戟之灵 S%02d/食戟之灵 S%02dE%02d.strm`, season, season, ep),
SeasonNum: season,
EpisodeNum: ep,
}
if err := svc.repo.DB.Create(&media).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
}
root, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, Limit: 50})
if err != nil {
t.Fatalf("library items: %v", err)
}
rootItems := root["Items"].([]map[string]any)
if len(rootItems) != 1 {
t.Fatalf("expected 1 series card for 食戟之灵 across 5 seasons, got %d cards: %#v", len(rootItems), rootItems)
}
if rootItems[0]["Name"] != "食戟之灵" || rootItems[0]["Type"] != "Series" {
t.Fatalf("unexpected series item: %#v", rootItems[0])
}
seriesID := rootItems[0]["Id"].(string)
seasons, err := svc.Items(t.Context(), ItemsParams{ParentID: seriesID, Limit: 50})
if err != nil {
t.Fatalf("series seasons: %v", err)
}
seasonItems := seasons["Items"].([]map[string]any)
if len(seasonItems) != 5 {
t.Fatalf("expected 5 seasons, got %d: %#v", len(seasonItems), seasonItems)
}
for i, s := range seasonItems {
wantSeasonNum := i + 1
if s["Type"] != "Season" || s["IndexNumber"] != wantSeasonNum {
t.Errorf("season [%d] = %#v, want IndexNumber=%d", i, s, wantSeasonNum)
}
}
counts, err := svc.ItemCounts(t.Context(), "user-1")
if err != nil {
t.Fatalf("item counts: %v", err)
}
if counts["SeriesCount"] != 1 || counts["EpisodeCount"] != int64(15) {
t.Fatalf("counts = %#v, want 1 series and 15 episodes", counts)
}
}
func TestInferSeriesNameFromPath(t *testing.T) {
tests := []struct {
path string
want string
}{
{
path: `/media/动漫/食戟之灵/食戟之灵 S01/食戟之灵 S01E01.strm`,
want: "食戟之灵",
},
{
path: `/media/动漫/食戟之灵/食戟之灵 S05/食戟之灵 S05E12.strm`,
want: "食戟之灵",
},
{
path: `/media/动漫/食戟之灵/Season 02/01.mkv`,
want: "食戟之灵",
},
{
path: `/media/动漫/进击的巨人 第2季/01.mkv`,
want: "进击的巨人",
},
{
path: `cloud://openlist/国漫/剑来/第二季/04.mkv`,
want: "剑来",
},
{
path: `/media/tv/间谍过家家 (2022)/Specials/S00E01.mkv`,
want: "间谍过家家",
},
}
for _, tc := range tests {
got := inferSeriesNameFromPath(tc.path)
if got != tc.want {
t.Errorf("inferSeriesNameFromPath(%q) = %q, want %q", tc.path, got, tc.want)
}
}
}
+30 -1
View File
@@ -31,6 +31,14 @@ func (e *EmbyService) seriesNameForMedia(m *model.Media) string {
return series.Title
}
}
if strings.EqualFold(strings.TrimSpace(m.ScrapeStatus), "matched") && strings.TrimSpace(m.Title) != "" {
name := strings.TrimSpace(m.Title)
name = embyEpisodeTitleRE.ReplaceAllString(name, "")
name = embyYearSuffixRE.ReplaceAllString(name, "")
if name != "" {
return name
}
}
if name := inferSeriesNameFromPath(m.Path); name != "" {
return name
}
@@ -53,14 +61,35 @@ func inferSeriesNameFromPath(path string) string {
if embySeasonDirRE.MatchString(base) {
dir = filepath.Dir(dir)
base = filepath.Base(dir)
} else if stripped := strings.TrimSpace(embySeasonSuffixRE.ReplaceAllString(base, "")); stripped != "" && stripped != base {
parentDir := filepath.Dir(dir)
parentBase := filepath.Base(parentDir)
if parentBase != "." && parentBase != string(filepath.Separator) && !isEmbyGenericContainer(parentBase) {
dir = parentDir
base = parentBase
} else {
base = stripped
}
}
base = strings.TrimSpace(embyYearSuffixRE.ReplaceAllString(base, ""))
if base == "." || base == string(filepath.Separator) {
if base == "." || base == string(filepath.Separator) || isEmbyGenericContainer(base) {
return ""
}
return base
}
func isEmbyGenericContainer(name string) bool {
switch strings.ToLower(strings.TrimSpace(name)) {
case "movie", "movies", "film", "films", "tv", "series", "show", "shows", "anime", "animation", "variety",
"电视剧", "剧集", "连续剧", "短剧", "国产剧", "国剧", "欧美剧", "美剧", "英剧", "日韩剧", "日剧", "韩剧", "港剧", "台剧", "港台剧",
"综艺", "纪录片", "儿童", "动漫", "番剧", "国漫", "日番", "韩漫", "美漫", "欧美动漫", "欧美动画", "其他动漫", "电影", "成人", "未分类",
"media", "downloads", "download", "videos", "video", "share", "shares":
return true
default:
return false
}
}
func stableEmbyID(prefix string, parts ...string) string {
h := sha256.New()
for _, part := range parts {
+2 -1
View File
@@ -9,6 +9,7 @@ import (
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/model"
)
@@ -50,7 +51,7 @@ func writeTempVideoWithSubtitle(t *testing.T, svc *EmbyService, lib *model.Libra
func newTestSubtitleService(t *testing.T, svc *EmbyService) *SubtitleService {
t.Helper()
return NewSubtitleService(zap.NewNop(), svc.repo)
return NewSubtitleService(&config.Config{}, zap.NewNop(), svc.repo)
}
func TestEmbyMediaStreamsAttachSameNameSubtitle(t *testing.T) {
+160
View File
@@ -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)")
}
+121
View File
@@ -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")
}
}
+10
View File
@@ -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)
}
}
+4
View File
@@ -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 构建服务容器。
+3 -1
View File
@@ -107,10 +107,12 @@ func (b *serviceContainerBuilder) initContentServices() {
b.c.Media = NewMediaService(b.cfg, b.log, b.repos).SetRuntimeCache(b.c.Cache)
b.c.Stream = NewStreamService(b.cfg, b.log, b.repos, b.c.Transcoder)
b.c.Playback = NewPlaybackService(b.log, b.repos)
b.c.Subtitle = NewSubtitleService(b.log, b.repos)
b.c.Subtitle = NewSubtitleService(b.cfg, b.log, b.repos)
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)
// 弹幕 hash 识别需要把 strm 指向解析成可拉取的直链/本地路径。
b.c.Danmaku.SetStrmResolver(b.c.Strm.ResolvePlay)
}
func (b *serviceContainerBuilder) initAccessAndStorageServices() {
+15
View File
@@ -86,6 +86,21 @@ func playableSTRMTarget(ctx context.Context, repo *repository.Container, raw str
return STRMPlaybackEnabled(ctx, repo)
}
// isStrmMediaRow 判断媒体行是否为 .strm(远程直链)媒体:STRMURL 非空、
// container=strm 或路径以 .strm 结尾。strm 媒体只能直连播放,禁止转码。
func isStrmMediaRow(m *model.Media) bool {
if m == nil {
return false
}
if strings.TrimSpace(m.STRMURL) != "" {
return true
}
if strings.EqualFold(strings.TrimSpace(m.Container), "strm") {
return true
}
return strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm")
}
func isHTTPPlaybackTarget(raw string) bool {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u == nil || !u.IsAbs() {
+11 -4
View File
@@ -22,11 +22,18 @@ func TestNormalizeCloudPlayTarget(t *testing.T) {
if parsed.IsAbs() || parsed.Host != "" {
t.Fatalf("normalized target should be relative, got %q", got)
}
if parsed.Query().Get("ref") != ref {
t.Fatalf("ref round-trip failed: %q", parsed.Query().Get("ref"))
}
if parsed.Query().Get("ref") != ref {
t.Fatalf("ref round-trip failed: %q", parsed.Query().Get("ref"))
}
// 非云盘播放 URL 保持原样(WebDAV/直链等)。
strmStale := "http://bwg.linkmy.fun:1314/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
gotStrm := normalizeCloudPlayTarget(strmStale)
wantStrm := "/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
if gotStrm != wantStrm {
t.Fatalf("normalizeCloudPlayTarget(strm) = %q, want %q", gotStrm, wantStrm)
}
// 非云盘播放 URL 保持原样(WebDAV/直链等)。
passthrough := "https://dav.example.com/media/file.mkv"
if got := normalizeCloudPlayTarget(passthrough); got != passthrough {
t.Fatalf("non-cloud target should pass through, got %q", got)
+14 -3
View File
@@ -15,11 +15,22 @@ import (
// /api/cloud/play 路径,由 absoluteInternalRedirect 基于「当前请求」补全
// host,从而对历史脏数据免疫。
func normalizeCloudPlayTarget(raw string) string {
typ, ref, ok := parseCloudMediaPlaybackURL(raw)
if !ok {
raw = strings.TrimSpace(raw)
if raw == "" {
return raw
}
return BuildRelativeCloudPlayURL(typ, ref)
if typ, ref, ok := parseCloudMediaPlaybackURL(raw); ok {
return BuildRelativeCloudPlayURL(typ, ref)
}
if u, err := url.Parse(raw); err == nil {
path := strings.ToLower(u.Path)
if strings.HasPrefix(path, "/api/strm/play/") || strings.HasPrefix(path, "/api/cloud/play/") || strings.HasPrefix(path, "/api/stream/") {
u.Scheme = ""
u.Host = ""
return u.String()
}
}
return raw
}
// BuildRelativeCloudPlayURL 构造相对的云盘播放 API 路径。
+12 -2
View File
@@ -66,7 +66,15 @@ func (s *StrmService) resolveCloudPlay(ctx context.Context, provider string, q u
if err != nil {
return nil, err
}
link, err := p.Resolve(ctx, ref)
var link *cloud.DirectLink
ua := q.Get("__ua")
if uaProvider, ok := p.(interface {
ResolveWithUA(ctx context.Context, fileRef, ua string) (*cloud.DirectLink, error)
}); ok && ua != "" {
link, err = uaProvider.ResolveWithUA(ctx, ref, ua)
} else {
link, err = p.Resolve(ctx, ref)
}
if err != nil {
return nil, err
}
@@ -76,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 本地源:校验路径位于某个本地同步目录的源目录内。
+9
View File
@@ -404,6 +404,9 @@ func (s *StrmService) CreateSyncPath(ctx context.Context, p *model.StrmSyncPath)
if strings.TrimSpace(p.Name) == "" {
p.Name = "同步目录 " + time.Now().Format("01-02 15:04")
}
if p.SyncMode == "" {
p.SyncMode = model.StrmSyncTypeIncremental
}
if p.EnableCron && strings.TrimSpace(p.Cron) == "" {
return nil, errors.New("启用定时同步需要填写 cron 表达式")
}
@@ -430,6 +433,12 @@ func (s *StrmService) UpdateSyncPath(ctx context.Context, id string, p *model.St
p.LastSyncAt = existing.LastSyncAt
p.LastSyncStatus = existing.LastSyncStatus
p.LastSyncMessage = existing.LastSyncMessage
if p.SyncMode == "" {
p.SyncMode = existing.SyncMode
if p.SyncMode == "" {
p.SyncMode = model.StrmSyncTypeIncremental
}
}
if p.EnableCron && strings.TrimSpace(p.Cron) == "" {
return nil, errors.New("启用定时同步需要填写 cron 表达式")
}
+539 -125
View File
@@ -13,12 +13,14 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/service/cloud"
"github.com/ShukeBta/MMTL/internal/service/cloud115"
)
// strmSyncState 是一次同步执行的上下文。
@@ -30,16 +32,24 @@ type strmSyncState struct {
provider cloud.Provider // local 提供方为 nil
cfg *strmPathConfig
rec *model.StrmSyncRecord
syncType string
mu sync.Mutex
processed int // 已处理文件计数(用于定期落库进度)
seenVideo map[string]bool // "v:"+去掉扩展名的相对路径 → 远端存在该视频
seenMeta map[string]bool // "m:"+相对路径 → 远端存在该元数据
remoteMeta map[string]int64 // 远端元数据大小(上传比对用)
mu sync.Mutex
processed int // 已处理文件计数(用于定期落库进度)
lastProgressFlush time.Time // 上次进度落库时间
seenVideo map[string]bool // "v:"+去掉扩展名的相对路径 → 远端存在该视频
seenMeta map[string]bool // "m:"+相对路径 → 远端存在该元数据
remoteMeta map[string]int64 // 远端元数据大小(上传比对用)
activeDownloadPaths map[string]bool // 本地已在排队/进行的下载任务路径(内存去重)
activeUploadPaths map[string]bool // 本地已在排队/进行的上传任务路径(内存去重)
pendingDownloads []*model.StrmDownloadTask
pendingUploads []*model.StrmUploadTask
dirCache sync.Map // dirID (string) -> relativePath (string)
}
// StartSync 启动一次同步(异步执行,同一目录同时只允许一个任务)。
func (s *StrmService) StartSync(ctx context.Context, pathID string) error {
// syncType 支持 "incremental"(默认增量)和 "full"(全量同步)。
func (s *StrmService) StartSync(ctx context.Context, pathID string, syncType ...string) error {
p, err := s.repo.StrmSyncPath.FindByID(ctx, pathID)
if err != nil || p == nil {
return errNotFoundOr(err, "同步目录不存在")
@@ -63,9 +73,20 @@ func (s *StrmService) StartSync(ctx context.Context, pathID string) error {
s.running[pathID] = cancel
s.mu.Unlock()
mode := model.StrmSyncTypeIncremental
if len(syncType) > 0 && syncType[0] != "" {
mode = syncType[0]
} else if p.SyncMode != "" {
mode = p.SyncMode
}
if mode != model.StrmSyncTypeFull {
mode = model.StrmSyncTypeIncremental
}
now := time.Now()
rec := &model.StrmSyncRecord{
SyncPathID: pathID,
SyncType: mode,
Status: model.StrmSyncRecordRunning,
StartedAt: &now,
}
@@ -144,6 +165,7 @@ func (s *StrmService) runSync(ctx context.Context, p *model.StrmSyncPath, rec *m
p: p,
cfg: cfg,
rec: rec,
syncType: rec.SyncType,
seenVideo: map[string]bool{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
@@ -190,15 +212,19 @@ func (s *StrmService) finishSync(p *model.StrmSyncPath, rec *model.StrmSyncRecor
p.LastSyncStatus = status
p.LastSyncMessage = message
if status != model.StrmSyncRecordFailed && message == "" {
p.LastSyncMessage = fmt.Sprintf("完成:新增/更新 %d 个 strm,下载 %d 个元数据,清理 %d 个文件",
rec.NewStrm, rec.NewMeta, rec.Pruned)
syncTypeLabel := "增量"
if rec.SyncType == model.StrmSyncTypeFull {
syncTypeLabel = "全量"
}
p.LastSyncMessage = fmt.Sprintf("[%s] 完成:新增/更新 %d 个 strm,跳过 %d 个,下载 %d 个元数据,清理 %d 个文件",
syncTypeLabel, rec.NewStrm, rec.Skipped, rec.NewMeta, rec.Pruned)
}
if err := s.repo.StrmSyncPath.Update(context.Background(), p); err != nil {
s.log.Warn("update strm sync path failed", zap.Error(err))
}
s.log.Info("strm sync finished",
zap.String("path_id", p.ID), zap.String("status", status),
zap.Int64("new_strm", rec.NewStrm), zap.Int64("new_meta", rec.NewMeta),
zap.String("path_id", p.ID), zap.String("sync_type", rec.SyncType), zap.String("status", status),
zap.Int64("new_strm", rec.NewStrm), zap.Int64("skipped", rec.Skipped), zap.Int64("new_meta", rec.NewMeta),
zap.Int64("pruned", rec.Pruned), zap.String("message", message))
}
@@ -206,20 +232,43 @@ func (st *strmSyncState) run() error {
if err := ensureLocalDir(st.p.LocalPath); err != nil {
return fmt.Errorf("创建输出目录失败:%w", err)
}
if st.cfg.DownloadMeta {
if active, err := st.s.repo.StrmDownload.GetActiveLocalPathMap(st.ctx, st.p.ID); err == nil {
st.activeDownloadPaths = active
} else {
st.activeDownloadPaths = map[string]bool{}
}
}
if st.cfg.UploadMeta {
if active, err := st.s.repo.StrmUpload.GetActiveLocalPathMap(st.ctx, st.p.ID); err == nil {
st.activeUploadPaths = active
} else {
st.activeUploadPaths = map[string]bool{}
}
}
if st.provider != nil {
if err := st.walkRemote(); err != nil {
return err
if open115, ok := st.provider.(cloud.OpenAPI115Provider); ok && st.p.Provider == model.StrmProvider115 {
if err := st.walk115Flat(open115.OpenClient()); err != nil {
return err
}
} else {
if err := st.walkRemote(); err != nil {
return err
}
}
} else {
if err := st.walkLocalSource(); err != nil {
return err
}
}
st.flushPendingDownloads()
st.flushProgress()
if st.cfg.UploadMeta && st.provider != nil && st.p.Provider != model.StrmProvider115 {
if err := st.scanLocalMetaForUpload(); err != nil {
return err
}
st.flushPendingUploads()
}
if err := st.pruneLocal(); err != nil {
return err
@@ -229,8 +278,16 @@ func (st *strmSyncState) run() error {
return nil
}
// walkRemote 广度优先遍历网盘目录树。
// strmScanWorkers 远端目录树并发遍历的 worker 数。115 开放平台有全局
// 令牌桶限流(QPS/QPM/QPH),并发请求自动排队,不会触发风控;并发让
// 多个目录列表请求的网络往返彼此重叠,大幅缩短大目录树同步耗时。
const strmScanWorkers = 8
// walkRemote 并发广度优先遍历网盘目录树。
// 多个 worker 并行执行 List(受全局 115 令牌桶限流约束),子目录动态
// 入队;任一目录失败则取消其余 worker 并返回错误(与旧串行版语义一致)。
func (st *strmSyncState) walkRemote() error {
defer st.flushPendingDownloads()
root := strings.TrimSpace(st.p.RemotePath)
if root == "" {
root = "/"
@@ -239,33 +296,78 @@ func (st *strmSyncState) walkRemote() error {
id string
rel string
}
queue := []dirTask{{id: root, rel: ""}}
for len(queue) > 0 {
select {
case <-st.ctx.Done():
return st.ctx.Err()
default:
}
task := queue[0]
queue = queue[1:]
entries, err := st.provider.List(st.ctx, task.id)
if err != nil {
return fmt.Errorf("列出远端目录 %s 失败:%w", task.id, err)
}
for _, entry := range entries {
cleanName := cleanEntryName(entry.Name, entry.IsDir)
rel := cleanName
if task.rel != "" {
rel = task.rel + "/" + cleanName
ctx, cancel := context.WithCancel(st.ctx)
defer cancel()
queue := make(chan dirTask, 512)
var pending atomic.Int64
// 根目录入队
pending.Add(1)
queue <- dirTask{id: root, rel: ""}
// 当队列中所有目录都被消费(pending 归零)或出错时关闭 channel,
// 让 worker 全部退出。
go func() {
for {
if ctx.Err() != nil || pending.Load() == 0 {
close(queue)
return
}
if entry.IsDir {
queue = append(queue, dirTask{id: entry.ID, rel: rel})
continue
}
st.processRemoteFile(entry, rel)
time.Sleep(10 * time.Millisecond)
}
}()
var (
wg sync.WaitGroup
errMu sync.Mutex
firstErr error
)
for i := 0; i < strmScanWorkers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for task := range queue {
if ctx.Err() != nil {
return
}
entries, err := st.provider.List(ctx, task.id)
if err != nil {
errMu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("列出远端目录 %s 失败:%w", task.id, err)
}
errMu.Unlock()
cancel()
return
}
for _, entry := range entries {
cleanName := cleanEntryName(entry.Name, entry.IsDir)
rel := cleanName
if task.rel != "" {
rel = task.rel + "/" + cleanName
}
if entry.IsDir {
pending.Add(1)
select {
case queue <- dirTask{id: entry.ID, rel: rel}:
case <-ctx.Done():
pending.Add(-1)
}
} else {
st.processRemoteFile(entry, rel)
}
}
pending.Add(-1)
}
}()
}
return nil
wg.Wait()
if firstErr != nil {
return firstErr
}
return ctx.Err()
}
// processRemoteFile 分类处理远端文件:视频生成 STRM,元数据入下载队列。
@@ -321,6 +423,216 @@ func (st *strmSyncState) isMetaExt(ext string) bool {
return false
}
// walk115Flat 使用 115 开放平台扁平化分页批量拉取机制与目录拓扑缓存(参考 QMediaSync)。
// 极大地降低 API 请求次数并支持毫秒级/秒级增量同步。
func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
defer st.flushPendingDownloads()
ctx := st.ctx
rootCID := strings.TrimSpace(st.p.RemotePath)
if rootCID == "" {
rootCID = "0"
}
// 1. 目录拓扑缓存处理
st.dirCache.Store(rootCID, "")
if st.syncType == model.StrmSyncTypeFull {
// 全量同步:清空本路径的历史目录缓存
if err := st.s.repo.StrmDirCache.DeleteBySyncPathID(ctx, st.p.ID); err != nil {
st.s.log.Warn("delete strm dir cache failed", zap.Error(err))
}
} else {
// 增量同步:预加载历史目录缓存
cached, err := st.s.repo.StrmDirCache.ListBySyncPathID(ctx, st.p.ID)
if err == nil {
for _, item := range cached {
st.dirCache.Store(item.DirID, item.Path)
}
}
}
// 2. 探测文件总数
const pageSize = 1150
firstBatch, totalCount, err := open115.GetFsListFlat(ctx, rootCID, 0, pageSize)
if err != nil {
return fmt.Errorf("115: 获取文件列表失败:%w", err)
}
allFiles := make([]cloud115.RemoteFile, 0, totalCount)
allFiles = append(allFiles, firstBatch...)
// 3. 并发分页拉取剩余文件
if totalCount > int64(len(firstBatch)) {
totalPages := int((totalCount + pageSize - 1) / pageSize)
type pageTask struct {
offset int
}
pageTasks := make([]pageTask, 0, totalPages-1)
for page := 1; page < totalPages; page++ {
pageTasks = append(pageTasks, pageTask{offset: page * pageSize})
}
var (
filesMu sync.Mutex
wg sync.WaitGroup
taskCh = make(chan pageTask, len(pageTasks))
errMu sync.Mutex
fetchErr error
)
for _, t := range pageTasks {
taskCh <- t
}
close(taskCh)
workers := 4
if len(pageTasks) < workers {
workers = len(pageTasks)
}
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for t := range taskCh {
if ctx.Err() != nil {
return
}
files, _, err := open115.GetFsListFlat(ctx, rootCID, t.offset, pageSize)
if err != nil {
errMu.Lock()
if fetchErr == nil {
fetchErr = err
}
errMu.Unlock()
return
}
filesMu.Lock()
allFiles = append(allFiles, files...)
filesMu.Unlock()
}
}()
}
wg.Wait()
if fetchErr != nil {
return fmt.Errorf("115: 分页拉取失败:%w", fetchErr)
}
}
if ctx.Err() != nil {
return ctx.Err()
}
// 4. 收集所有未在缓存中的父目录 ID (file.Pid)
missingPids := make(map[string]struct{})
for _, f := range allFiles {
pid := f.Pid
if pid == "" || pid == rootCID {
continue
}
if _, ok := st.dirCache.Load(pid); !ok {
missingPids[pid] = struct{}{}
}
}
// 并发补全未知目录详情与祖先链
if len(missingPids) > 0 {
pidList := make([]string, 0, len(missingPids))
for pid := range missingPids {
pidList = append(pidList, pid)
}
pidCh := make(chan string, len(pidList))
for _, pid := range pidList {
pidCh <- pid
}
close(pidCh)
var (
pwg sync.WaitGroup
dirWorkers = 4
)
if len(pidList) < dirWorkers {
dirWorkers = len(pidList)
}
for i := 0; i < dirWorkers; i++ {
pwg.Add(1)
go func() {
defer pwg.Done()
for pid := range pidCh {
if ctx.Err() != nil {
return
}
detail, err := open115.GetFsDetailByCid(ctx, pid)
if err != nil {
st.s.log.Warn("115: 获取目录详情失败", zap.String("pid", pid), zap.Error(err))
continue
}
if detail == nil {
continue
}
// 解析相对路径
relPath := detail.RelativePath(rootCID)
st.dirCache.Store(pid, relPath)
_ = st.s.repo.StrmDirCache.Set(ctx, st.p.ID, pid, relPath)
// 顺便解析并缓存 detail.Paths 中包含的中间各层级目录
for _, ancestor := range detail.Paths {
if ancestor.FileId == "0" || ancestor.FileId == rootCID {
continue
}
if _, loaded := st.dirCache.Load(ancestor.FileId); !loaded {
subDetail := &cloud115.RemoteFileDetail{
FileId: ancestor.FileId,
Paths: nil,
}
for _, p := range detail.Paths {
subDetail.Paths = append(subDetail.Paths, p)
if p.FileId == ancestor.FileId {
break
}
}
ancestorRel := subDetail.RelativePath(rootCID)
st.dirCache.Store(ancestor.FileId, ancestorRel)
_ = st.s.repo.StrmDirCache.Set(ctx, st.p.ID, ancestor.FileId, ancestorRel)
}
}
}
}()
}
pwg.Wait()
}
// 5. 分类处理所有文件
for _, f := range allFiles {
if ctx.Err() != nil {
return ctx.Err()
}
cleanName := cleanEntryName(f.FileName, false)
var rel string
if f.Pid == "" || f.Pid == rootCID {
rel = cleanName
} else {
if parentVal, ok := st.dirCache.Load(f.Pid); ok && parentVal.(string) != "" {
rel = parentVal.(string) + "/" + cleanName
} else {
rel = cleanName
}
}
entry := cloud.FileEntry{
ID: f.FileId,
Name: f.FileName,
IsDir: false,
Size: f.FileSize,
MTime: f.Utime,
PickCode: f.PickCode,
}
st.processRemoteFile(entry, rel)
}
return nil
}
// handleVideo 生成/更新 .strm 文件。
func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
relSansExt := rel[:len(rel)-len(ext)]
@@ -338,9 +650,22 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
st.s.log.Warn("strm target path out of root", zap.String("rel", targetRel), zap.Error(err))
return
}
// 增量同步模式快速检查:本地 strm 文件存在、非空且修改时间与远端 mtime 一致,直接跳过无需读磁盘
if st.syncType == model.StrmSyncTypeIncremental && entry.MTime > 0 {
if info, err := os.Stat(target); err == nil && info.Size() > 0 && info.ModTime().Unix() == entry.MTime {
st.mu.Lock()
st.rec.Skipped++
st.mu.Unlock()
st.touchProgress()
return
}
}
content, err := st.strmContent(entry, rel, ext)
if err != nil {
st.rec.Message = err.Error()
// 并发 worker 下 rec.Message 无锁写会有数据竞争,这里仅记录日志;
// 最终同步结果的 message 由 finishSync 统一填充。
st.s.log.Warn("build strm content failed", zap.String("file", rel), zap.Error(err))
return
}
@@ -349,6 +674,11 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
existing = string(data)
}
if existing == content {
// 对齐本地 strm 修改时间为远端 mtime,便于后续秒级比对
if entry.MTime > 0 {
mTime := time.Unix(entry.MTime, 0)
_ = os.Chtimes(target, mTime, mTime)
}
st.mu.Lock()
st.rec.Skipped++
st.mu.Unlock()
@@ -369,6 +699,10 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
st.s.log.Warn("rename strm failed", zap.String("file", target), zap.Error(err))
return
}
if entry.MTime > 0 {
mTime := time.Unix(entry.MTime, 0)
_ = os.Chtimes(target, mTime, mTime)
}
st.mu.Lock()
st.rec.NewStrm++
st.mu.Unlock()
@@ -428,6 +762,36 @@ func (st *strmSyncState) recordRemoteMeta(entry cloud.FileEntry, rel string) {
st.mu.Unlock()
}
func (st *strmSyncState) flushPendingDownloads() {
st.mu.Lock()
if len(st.pendingDownloads) == 0 {
st.mu.Unlock()
return
}
batch := st.pendingDownloads
st.pendingDownloads = nil
st.mu.Unlock()
if err := st.s.repo.StrmDownload.CreateInBatches(st.ctx, batch, 100); err != nil {
st.s.log.Warn("batch enqueue strm download tasks failed", zap.Error(err))
}
}
func (st *strmSyncState) flushPendingUploads() {
st.mu.Lock()
if len(st.pendingUploads) == 0 {
st.mu.Unlock()
return
}
batch := st.pendingUploads
st.pendingUploads = nil
st.mu.Unlock()
if err := st.s.repo.StrmUpload.CreateInBatches(st.ctx, batch, 100); err != nil {
st.s.log.Warn("batch enqueue strm upload tasks failed", zap.Error(err))
}
}
// handleMeta 元数据入下载队列(本地已存在且大小一致则跳过)。
func (st *strmSyncState) handleMeta(entry cloud.FileEntry, rel, ext string) {
st.recordRemoteMeta(entry, rel)
@@ -440,10 +804,22 @@ func (st *strmSyncState) handleMeta(entry cloud.FileEntry, rel, ext string) {
st.touchProgress()
return
}
if st.taskExists("download", st.p.ID, target) {
st.mu.Lock()
if st.activeDownloadPaths == nil {
if active, err := st.s.repo.StrmDownload.GetActiveLocalPathMap(st.ctx, st.p.ID); err == nil {
st.activeDownloadPaths = active
} else {
st.activeDownloadPaths = map[string]bool{}
}
}
if st.activeDownloadPaths[target] {
st.mu.Unlock()
st.touchProgress()
return
}
st.activeDownloadPaths[target] = true
st.mu.Unlock()
task := &model.StrmDownloadTask{
SyncPathID: st.p.ID,
AccountID: st.p.AccountID,
@@ -462,13 +838,16 @@ func (st *strmSyncState) handleMeta(entry cloud.FileEntry, rel, ext string) {
if st.p.Provider != model.StrmProvider115 {
task.RemoteRef = entry.ID
}
if err := st.s.repo.StrmDownload.Create(st.ctx, task); err != nil {
st.s.log.Warn("enqueue strm download task failed", zap.Error(err))
return
}
st.mu.Lock()
st.pendingDownloads = append(st.pendingDownloads, task)
shouldFlush := len(st.pendingDownloads) >= 100
st.rec.NewMeta++
st.mu.Unlock()
if shouldFlush {
st.flushPendingDownloads()
}
st.touchProgress()
}
@@ -522,93 +901,124 @@ func (st *strmSyncState) walkLocalSource() error {
if err != nil {
return nil
}
target, err := joinLocalRel(st.p.LocalPath, relSansExt+".strm")
if err != nil {
return nil
}
if data, err := os.ReadFile(target); err == nil && string(data) == content {
target, err := joinLocalRel(st.p.LocalPath, relSansExt+".strm")
if err != nil {
return nil
}
mTime := info.ModTime()
if st.syncType == model.StrmSyncTypeIncremental {
if tInfo, err := os.Stat(target); err == nil && tInfo.Size() > 0 && tInfo.ModTime().Unix() == mTime.Unix() {
st.mu.Lock()
st.rec.Skipped++
st.mu.Unlock()
st.touchProgress()
return nil
}
}
if data, err := os.ReadFile(target); err == nil && string(data) == content {
_ = os.Chtimes(target, mTime, mTime)
st.mu.Lock()
st.rec.Skipped++
st.mu.Unlock()
st.touchProgress()
return nil
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return nil
}
tmp := target + ".tmp"
if err := os.WriteFile(tmp, []byte(content), 0o644); err == nil {
_ = os.Rename(tmp, target)
_ = os.Chtimes(target, mTime, mTime)
} else {
_ = os.Remove(tmp)
}
st.mu.Lock()
st.rec.Skipped++
st.rec.NewStrm++
st.mu.Unlock()
st.touchProgress()
return nil
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return nil
}
tmp := target + ".tmp"
if err := os.WriteFile(tmp, []byte(content), 0o644); err == nil {
_ = os.Rename(tmp, target)
} else {
_ = os.Remove(tmp)
}
st.mu.Lock()
st.rec.NewStrm++
st.mu.Unlock()
return nil
})
}
// scanLocalMetaForUpload 扫描本地元数据,与远端比对后入上传队列。
func (st *strmSyncState) scanLocalMetaForUpload() error {
localRoot := filepath.Clean(st.p.LocalPath)
return filepath.WalkDir(localRoot, func(path string, d os.DirEntry, err error) error {
if err != nil {
// scanLocalMetaForUpload 扫描本地元数据,与远端比对后入上传队列。
func (st *strmSyncState) scanLocalMetaForUpload() error {
defer st.flushPendingUploads()
if st.activeUploadPaths == nil {
if active, err := st.s.repo.StrmUpload.GetActiveLocalPathMap(st.ctx, st.p.ID); err == nil {
st.activeUploadPaths = active
} else {
st.activeUploadPaths = map[string]bool{}
}
}
localRoot := filepath.Clean(st.p.LocalPath)
return filepath.WalkDir(localRoot, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if path == localRoot {
return nil
}
select {
case <-st.ctx.Done():
return st.ctx.Err()
default:
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(localRoot, path)
if err != nil {
return nil
}
rel = filepath.ToSlash(rel)
ext := strings.ToLower(filepath.Ext(rel))
if !st.isMetaExt(ext) {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
st.mu.Lock()
_, exists := st.remoteMeta["m:"+rel]
st.mu.Unlock()
if exists {
// 网盘端已存在该元数据文件,跳过上传
return nil
}
st.mu.Lock()
if st.activeUploadPaths != nil && st.activeUploadPaths[path] {
st.mu.Unlock()
return nil
}
if st.activeUploadPaths != nil {
st.activeUploadPaths[path] = true
}
st.mu.Unlock()
task := &model.StrmUploadTask{
SyncPathID: st.p.ID,
AccountID: st.p.AccountID,
Provider: st.p.Provider,
FileName: filepath.Base(rel),
LocalPath: path,
RemotePath: st.remoteUploadPath(rel),
Size: info.Size(),
Status: model.StrmTaskPending,
}
st.mu.Lock()
st.pendingUploads = append(st.pendingUploads, task)
shouldFlush := len(st.pendingUploads) >= 100
st.rec.Uploaded++
st.mu.Unlock()
if shouldFlush {
st.flushPendingUploads()
}
return nil
}
if path == localRoot {
return nil
}
select {
case <-st.ctx.Done():
return st.ctx.Err()
default:
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(localRoot, path)
if err != nil {
return nil
}
rel = filepath.ToSlash(rel)
ext := strings.ToLower(filepath.Ext(rel))
if !st.isMetaExt(ext) {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
st.mu.Lock()
_, exists := st.remoteMeta["m:"+rel]
st.mu.Unlock()
if exists {
// 网盘端已存在该元数据文件,跳过上传
return nil
}
if st.taskExists("upload", st.p.ID, path) {
return nil
}
task := &model.StrmUploadTask{
SyncPathID: st.p.ID,
AccountID: st.p.AccountID,
Provider: st.p.Provider,
FileName: filepath.Base(rel),
LocalPath: path,
RemotePath: st.remoteUploadPath(rel),
Size: info.Size(),
Status: model.StrmTaskPending,
}
if err := st.s.repo.StrmUpload.Create(st.ctx, task); err != nil {
st.s.log.Warn("enqueue strm upload task failed", zap.Error(err))
return nil
}
st.mu.Lock()
st.rec.Uploaded++
st.mu.Unlock()
return nil
})
}
})
}
// remoteUploadPath 远端元数据目标路径 = 同步目录远端根 + 相对路径。
func (st *strmSyncState) remoteUploadPath(rel string) string {
@@ -694,12 +1104,16 @@ func (st *strmSyncState) pruneLocal() error {
return nil
}
// touchProgress 每处理若干个文件落库一次进度。
// touchProgress 进度计数并限流防抖落库(避免高频写 SQLite 导致锁竞争)。
func (st *strmSyncState) touchProgress() {
st.mu.Lock()
st.rec.Total++
st.processed++
flush := st.processed%100 == 0
now := time.Now()
flush := st.processed%100 == 0 || (st.processed%20 == 0 && now.Sub(st.lastProgressFlush) >= 2*time.Second)
if flush {
st.lastProgressFlush = now
}
st.mu.Unlock()
if flush {
st.flushProgress()
+223 -4
View File
@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -17,6 +18,7 @@ import (
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service/cloud"
)
// testStrmService 构建带内存库的 StrmService。
@@ -33,10 +35,10 @@ func testStrmService(t *testing.T) *StrmService {
sqlDB.SetMaxOpenConns(4)
t.Cleanup(func() { _ = sqlDB.Close() })
}
if err := db.AutoMigrate(&model.StrmAccount{}, &model.StrmSyncPath{}, &model.StrmSyncRecord{},
&model.StrmDownloadTask{}, &model.StrmUploadTask{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.StrmAccount{}, &model.StrmSyncPath{}, &model.StrmSyncRecord{},
&model.StrmDownloadTask{}, &model.StrmUploadTask{}, &model.StrmDirCache{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
ctx := context.Background()
if err := repos.Setting.Set(ctx, StrmSettingBaseURL, "http://test.local:8096"); err != nil {
@@ -158,6 +160,57 @@ func TestLocalStrmSync(t *testing.T) {
}
}
// TestStrmFullAndIncrementalSync 测试增量同步与全量同步模式切换及记录
func TestStrmFullAndIncrementalSync(t *testing.T) {
svc := testStrmService(t)
src := t.TempDir()
out := t.TempDir()
writeFile(t, filepath.Join(src, "电影", "星际穿越.mkv"), "fake-video-data")
p := syncPathRecord(t, svc, model.StrmProviderLocal, src, out, true)
// 1. 默认触发增量同步
if err := svc.StartSync(context.Background(), p.ID, model.StrmSyncTypeIncremental); err != nil {
t.Fatal(err)
}
record := waitSyncDone(t, svc, p.ID, 10*time.Second)
if record.Status != model.StrmSyncRecordDone {
t.Fatalf("sync status = %s, message = %s", record.Status, record.Message)
}
if record.SyncType != model.StrmSyncTypeIncremental {
t.Fatalf("expected sync_type = incremental, got %s", record.SyncType)
}
if record.NewStrm != 1 {
t.Fatalf("expected 1 new strm, got %d", record.NewStrm)
}
// 2. 再次执行增量同步,应当跳过
if err := svc.StartSync(context.Background(), p.ID, model.StrmSyncTypeIncremental); err != nil {
t.Fatal(err)
}
record = waitSyncDone(t, svc, p.ID, 10*time.Second)
if record.SyncType != model.StrmSyncTypeIncremental {
t.Fatalf("expected sync_type = incremental, got %s", record.SyncType)
}
if record.Skipped != 1 {
t.Fatalf("expected 1 skipped, got %d", record.Skipped)
}
// 3. 执行全量同步
if err := svc.StartSync(context.Background(), p.ID, model.StrmSyncTypeFull); err != nil {
t.Fatal(err)
}
record = waitSyncDone(t, svc, p.ID, 10*time.Second)
if record.SyncType != model.StrmSyncTypeFull {
t.Fatalf("expected sync_type = full, got %s", record.SyncType)
}
if record.Status != model.StrmSyncRecordDone {
t.Fatalf("full sync failed: status = %s, message = %s", record.Status, record.Message)
}
}
// TestStrmCronMatches cron 表达式匹配。
func TestStrmCronMatches(t *testing.T) {
cases := []struct {
@@ -340,3 +393,169 @@ func TestScanLocalMetaForUpload(t *testing.T) {
t.Fatalf("expected still 1 upload task after dedup, got %d", len(tasks))
}
}
// fakeRemoteProvider 是 walkRemote 并发遍历的假提供方:返回一棵固定目录树,
// 并记录每个目录被 List 的次数,用于验证并发遍历无漏目录、无重复目录。
type fakeRemoteProvider struct {
listed map[string]int
}
func (f *fakeRemoteProvider) Type() string { return "fake" }
func (f *fakeRemoteProvider) Ping(context.Context) error { return nil }
func (f *fakeRemoteProvider) Resolve(context.Context, string) (*cloud.DirectLink, error) {
return &cloud.DirectLink{URL: "http://cdn/x.mkv"}, nil
}
func (f *fakeRemoteProvider) List(_ context.Context, dirID string) ([]cloud.FileEntry, error) {
if f.listed == nil {
f.listed = map[string]int{}
}
f.listed[dirID]++
switch dirID {
case "root":
return []cloud.FileEntry{
{ID: "a", Name: "动漫", IsDir: true},
{ID: "b", Name: "电影", IsDir: true},
{ID: "f1", Name: "孤儿视频.mkv", Size: 100},
}, nil
case "a":
return []cloud.FileEntry{
{ID: "a1", Name: "番剧", IsDir: true},
{ID: "fa1", Name: "第01集.mkv", Size: 200},
}, nil
case "a1":
return []cloud.FileEntry{
{ID: "fa11", Name: "第01集.mkv", Size: 300},
{ID: "fa12", Name: "第02集.mkv", Size: 300},
}, nil
case "b":
return []cloud.FileEntry{
{ID: "fb1", Name: "电影A.mkv", Size: 400},
}, nil
default:
return nil, nil
}
}
// TestWalkRemoteConcurrent 验证并发目录遍历:所有目录均被列出、所有文件
// 均被处理(strm 生成 / 元数据入队),且不重复。
func TestWalkRemoteConcurrent(t *testing.T) {
svc := testStrmService(t)
localDir := t.TempDir()
acct := &model.StrmAccount{
Name: "fake",
Provider: "cloud115",
Config: "{}",
Enabled: true,
}
if err := svc.repo.StrmAccount.Create(context.Background(), acct); err != nil {
t.Fatal(err)
}
p := &model.StrmSyncPath{
Base: model.Base{ID: "walk-path"},
AccountID: acct.ID,
Provider: model.StrmProvider115,
RemotePath: "root",
LocalPath: localDir,
}
provider := &fakeRemoteProvider{listed: map[string]int{}}
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
provider: provider,
cfg: &strmPathConfig{VideoExt: []string{"mkv"}, MetaExt: []string{"nfo"}, AddPath: 1, DownloadMeta: false},
rec: &model.StrmSyncRecord{},
seenVideo: map[string]bool{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
}
if err := st.walkRemote(); err != nil {
t.Fatalf("walkRemote failed: %v", err)
}
for _, dir := range []string{"root", "a", "a1", "b"} {
if provider.listed[dir] != 1 {
t.Errorf("目录 %s 被列出 %d 次,期望 1 次", dir, provider.listed[dir])
}
}
// 5 个视频文件应生成 5 个 .strm:孤儿视频.mkv / a目录第01集 /
// 番剧第01集+第02集 / 电影A(递归统计,含子目录)
strmCount := 0
walkErr := filepath.WalkDir(localDir, func(path string, d os.DirEntry, err error) error {
if err == nil && !d.IsDir() && strings.HasSuffix(d.Name(), ".strm") {
strmCount++
}
return nil
})
if walkErr != nil {
t.Fatal(walkErr)
}
if strmCount != 5 {
t.Errorf("生成的 .strm 数量 = %d,期望 5", strmCount)
}
}
// TestStrmBatchEnqueueAndConcurrentClaim 测试大规模批量入库及多协程并发认领无死锁
func TestStrmBatchEnqueueAndConcurrentClaim(t *testing.T) {
svc := testStrmService(t)
ctx := context.Background()
// 1. 批量插入 200 个下载任务
tasks := make([]*model.StrmDownloadTask, 0, 200)
for i := 0; i < 200; i++ {
tasks = append(tasks, &model.StrmDownloadTask{
SyncPathID: "test-sync-path",
AccountID: "test-acct",
Provider: model.StrmProvider115,
FileName: filepath.Base(string(rune('a'+i%26))) + ".nfo",
LocalPath: filepath.Join(t.TempDir(), string(rune('a'+i%26)), "test.nfo"),
Status: model.StrmTaskPending,
})
}
if err := svc.repo.StrmDownload.CreateInBatches(ctx, tasks, 50); err != nil {
t.Fatalf("CreateInBatches failed: %v", err)
}
// 2. 验证 ActiveLocalPathMap
activeMap, err := svc.repo.StrmDownload.GetActiveLocalPathMap(ctx, "test-sync-path")
if err != nil {
t.Fatalf("GetActiveLocalPathMap failed: %v", err)
}
if len(activeMap) == 0 {
t.Fatal("expected active local path map to have entries")
}
// 3. 模拟 6 个 worker 并发 ClaimPendingDownload
claimedCount := 0
var claimMu sync.Mutex
var wg sync.WaitGroup
for w := 0; w < 6; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
batch, err := svc.repo.StrmDownload.ClaimPendingDownload(ctx, 10)
if err != nil {
t.Errorf("concurrent ClaimPendingDownload failed: %v", err)
return
}
if len(batch) == 0 {
return
}
claimMu.Lock()
claimedCount += len(batch)
claimMu.Unlock()
}
}()
}
wg.Wait()
if claimedCount != 200 {
t.Fatalf("expected all 200 tasks claimed, got %d", claimedCount)
}
}
+182 -7
View File
@@ -1,8 +1,8 @@
// Package service — subtitle handling.
//
// SubtitleService finds external subtitle files next to a media file and
// converts SRT to WebVTT on the fly so the browser <track> element can
// load them directly.
// SubtitleService finds external subtitle files next to a media file AND
// embedded text subtitle tracks inside the media container, exposing both as
// WebVTT so the browser <track> element can load them directly.
//
// External-subtitle discovery rules (matching the legacy Python defaults):
//
@@ -12,20 +12,29 @@
// ?lang=zh / ?lang=en.
//
// Supported extensions: .srt, .ass, .ssa, .vtt.
//
// Embedded subtitles are probed with ffprobe and exposed as
// path "embedded:<stream-index>"; the browser endpoint extracts the stream
// via ffmpeg into a cached .vtt file.
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/repository"
)
@@ -33,11 +42,12 @@ import (
type SubtitleService struct {
log *zap.Logger
repo *repository.Container
cfg *config.Config
}
// NewSubtitleService is the constructor.
func NewSubtitleService(log *zap.Logger, repo *repository.Container) *SubtitleService {
return &SubtitleService{log: log, repo: repo}
func NewSubtitleService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *SubtitleService {
return &SubtitleService{log: log, repo: repo, cfg: cfg}
}
// SubtitleTrack describes one external subtitle file.
@@ -108,9 +118,94 @@ func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]Subti
})
}
}
// 容器内嵌文本字幕轨(MKV/MP4 等封装内的字幕流):本地真实文件才可
// 探测提取;cloud:// 与 .strm 媒体跳过。探测失败静默忽略(无 ffprobe
// 或没有字幕流都属正常)。
if embedded, ok := s.discoverEmbeddedTracks(ctx, m.Path); ok {
tracks = append(tracks, embedded...)
}
return tracks, nil
}
// embeddedCodecOK 只暴露可提取为 WebVTT 的文本字幕编解码器;位图字幕
// (PGS/DVDSUB/DVBSUB)浏览器无法渲染,跳过。
func embeddedCodecOK(codec string) bool {
switch strings.ToLower(codec) {
case "subrip", "srt", "mov_text", "text", "webvtt", "ass", "ssa", "ttml", "sami":
return true
default:
return false
}
}
// ffprobeSubtitleStream 是 ffprobe -show_streams 输出的字幕流字段。
type ffprobeSubtitleStream struct {
Index int `json:"index"`
Codec string `json:"codec_name"`
Tags map[string]string `json:"tags"`
}
type ffprobeSubtitleContainer struct {
Streams []ffprobeSubtitleStream `json:"streams"`
}
// discoverEmbeddedTracks 用 ffprobe 探测媒体容器内的文本字幕轨。
// 返回 (tracks, ok):ok=false 表示该媒体不适用(非本地文件/ffprobe 不可用)。
func (s *SubtitleService) discoverEmbeddedTracks(ctx context.Context, mediaPath string) ([]SubtitleTrack, bool) {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(mediaPath)), "cloud://") ||
strings.HasSuffix(strings.ToLower(strings.TrimSpace(mediaPath)), ".strm") {
return nil, false
}
bin, err := resolveLocalExecutable(s.cfg.App.FFprobePath, "ffprobe")
if err != nil {
return nil, false
}
if _, err := os.Stat(mediaPath); err != nil {
return nil, false
}
probeCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
cmd := exec.CommandContext(probeCtx, bin, // #nosec G204 -- bin resolved by resolveLocalExecutable; args are fixed probes.
"-v", "error",
"-select_streams", "s",
"-show_entries", "stream=index,codec_name:stream_tags=language,title",
"-of", "json",
"--", mediaPath,
)
out, err := cmd.Output()
if err != nil {
return nil, false
}
var container ffprobeSubtitleContainer
if err := json.Unmarshal(out, &container); err != nil {
return nil, false
}
tracks := make([]SubtitleTrack, 0, len(container.Streams))
for _, stream := range container.Streams {
if !embeddedCodecOK(stream.Codec) {
continue
}
lang := strings.ToLower(strings.TrimSpace(stream.Tags["language"]))
if lang == "" {
lang = "und"
}
label := stream.Tags["title"]
if label == "" {
label = lang
}
tracks = append(tracks, SubtitleTrack{
Lang: lang,
Label: "内置字幕 · " + label,
Path: "embedded:" + strconv.Itoa(stream.Index),
Codec: stream.Codec,
})
}
return tracks, true
}
// langTag matches the .zh / .zh-cn / .chs language sub-extensions.
var langTag = regexp.MustCompile(`(?i)\.([a-z]{2,3}(?:[-_][a-z]{2,4})?)$`)
@@ -127,13 +222,18 @@ func detectLang(name, base string) string {
}
// Serve writes the subtitle file as WebVTT (.vtt). SRT/SSA files are
// converted minimally on the fly. Returns ErrSubtitleNotFound when the
// path is rejected (path traversal / not in the media directory).
// converted minimally on the fly; embedded container tracks (path
// "embedded:<index>") are extracted via ffmpeg into a cached .vtt.
// Returns ErrSubtitleNotFound when the path is rejected (path traversal /
// not in the media directory).
func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.Writer) error {
m, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil || m == nil {
return errors.New("media not found")
}
if strings.HasPrefix(sub, "embedded:") {
return s.ServeEmbeddedToVTT(ctx, m.Path, sub, w)
}
abs, err := filepath.Abs(sub)
if err != nil {
return err
@@ -166,6 +266,81 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W
return err
}
// embeddedSubtitleCachePath 内嵌字幕提取后的 WebVTT 缓存路径
// (按媒体路径哈希 + 轨道号定位,跨媒体互不干扰)。
func (s *SubtitleService) embeddedSubtitleCachePath(mediaPath string, idx int) string {
hash := fmt.Sprintf("%x", fnvHash(mediaPath))
return filepath.Join(s.cfg.Cache.CacheDir, "subs", hash, fmt.Sprintf("s%d.vtt", idx))
}
// ServeEmbeddedToVTT 把容器内第 idx 个字幕轨提取为 WebVTT 输出。
// 提取结果缓存在 cache 目录,媒体文件更新(mtime 变化)后自动重新提取。
func (s *SubtitleService) ServeEmbeddedToVTT(ctx context.Context, mediaPath, streamRef string, w io.Writer) error {
idx, err := strconv.Atoi(strings.TrimPrefix(streamRef, "embedded:"))
if err != nil || idx < 0 {
return errors.New("invalid embedded subtitle index")
}
ffmpegBin, err := resolveLocalExecutable(s.cfg.App.FFmpegPath, "ffmpeg")
if err != nil {
return fmt.Errorf("ffmpeg 不可用,无法提取内嵌字幕:%w", err)
}
info, err := os.Stat(mediaPath)
if err != nil {
return errors.New("media file not found")
}
cachePath := s.embeddedSubtitleCachePath(mediaPath, idx)
if cached, statErr := os.Stat(cachePath); statErr == nil && !info.ModTime().After(cached.ModTime()) {
f, openErr := os.Open(cachePath) // #nosec G304 -- cachePath is generated under the cache dir.
if openErr == nil {
defer f.Close()
_, copyErr := io.Copy(w, f)
return copyErr
}
}
// 缓存未命中或媒体已更新:ffmpeg 提取到临时文件后原子改名。
if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil {
return err
}
tmp := cachePath + ".tmp"
extractCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
cmd := exec.CommandContext(extractCtx, ffmpegBin, // #nosec G204 -- bin resolved by resolveLocalExecutable; args fixed extraction.
"-v", "error", "-y",
"-i", mediaPath,
"-map", "0:s:"+strconv.Itoa(idx),
"-f", "webvtt",
tmp,
)
if out, runErr := cmd.CombinedOutput(); runErr != nil {
_ = os.Remove(tmp)
return fmt.Errorf("提取内嵌字幕失败(轨道 %d,可能为位图字幕或轨道无效):%s", idx, strings.TrimSpace(string(out)))
}
if err := os.Rename(tmp, cachePath); err != nil {
_ = os.Remove(tmp)
return err
}
f, err := os.Open(cachePath) // #nosec G304 -- cachePath is generated under the cache dir.
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(w, f)
return err
}
// fnvHash 简单 32 位 FNV-1a 哈希,用于生成稳定的缓存子目录名。
func fnvHash(s string) uint32 {
var h uint32 = 2166136261
for i := 0; i < len(s); i++ {
h ^= uint32(s[i])
h *= 16777619
}
return h
}
// ServeRaw writes the subtitle file in its original format without any
// WebVTT conversion. Emby/Jellyfin clients advertise the source codec (ASS,
// subrip, etc.) in MediaStreams, then fetch the subtitle bytes via the
+3 -2
View File
@@ -10,6 +10,7 @@ import (
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
)
@@ -32,7 +33,7 @@ func TestSubtitleDiscoverNoTracksReturnsEmptySlice(t *testing.T) {
t.Fatal(err)
}
svc := NewSubtitleService(zap.NewNop(), repository.New(db))
svc := NewSubtitleService(&config.Config{}, zap.NewNop(), repository.New(db))
tracks, err := svc.Discover(t.Context(), media.ID)
if err != nil {
t.Fatal(err)
@@ -85,7 +86,7 @@ func TestSubtitleServeRawWritesSourceBytes(t *testing.T) {
t.Fatal(err)
}
svc := NewSubtitleService(zap.NewNop(), repository.New(db))
svc := NewSubtitleService(&config.Config{}, zap.NewNop(), repository.New(db))
var buf bytes.Buffer
if err := svc.ServeRaw(t.Context(), media.ID, subPath, &buf); err != nil {
t.Fatal(err)
+6
View File
@@ -101,6 +101,12 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
if m == nil {
return "", ErrMediaNotFound
}
// .strm 媒体(STRMURL 或 container=strm / *.strm 路径)的内容是远程
// 直链文本,ffmpeg 无法读取,转码必然失败且白白消耗资源。直接拒绝
// 转码,迫使播放器走 /api/stream 302 直连播放。
if isStrmMediaRow(m) {
return "", ErrTranscodeDisabled
}
if _, err := os.Stat(m.Path); err != nil {
return "", ErrMediaNotFound
}
+13 -5
View File
@@ -123,12 +123,20 @@ func LibraryVisibleForUser(ctx context.Context, repo *repository.Container, lib
return false
}
if repo != nil && repo.DB != nil {
var count int64
var totalCount int64
_ = repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("library_id = ? AND nsfw = ?", lib.ID, true).
Count(&count).Error
if count > 0 {
return false
Where("library_id = ?", lib.ID).
Count(&totalCount).Error
if totalCount > 0 {
var nsfwCount int64
_ = repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("library_id = ? AND nsfw = ?", lib.ID, true).
Count(&nsfwCount).Error
// 仅当整库媒体全部为成人内容(纯成人库)时才隐藏整库;
// 含有普通内容的混合媒体库保持库本身可见,具体 NSFW 条目在媒体列表内过滤。
if nsfwCount == totalCount {
return false
}
}
}
return true
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+13
View File
@@ -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 = {
+2 -1
View File
@@ -107,7 +107,8 @@ export const strmAPI = {
deletePath: (id: string) => api.delete(`/admin/strm/paths/${id}`).then((r) => r.data),
startSync: (id: string) => api.post(`/admin/strm/paths/${id}/sync`).then((r) => r.data),
startSync: (id: string, mode: 'incremental' | 'full' = 'incremental') =>
api.post(`/admin/strm/paths/${id}/sync`, null, { params: { mode } }).then((r) => r.data),
cancelSync: (id: string) => api.post(`/admin/strm/paths/${id}/cancel`).then((r) => r.data),
+31 -4
View File
@@ -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,10 @@ export function DanmakuStage({
applyLiveSettings()
const loadDanmaku = async () => {
let loadedInfo: DanmakuLoadedInfo | null = null
comments = []
nextIndex = 0
manager.clear()
try {
const res = await danmakuAPI.fetch(media.id, {
kw: search ?? undefined,
@@ -136,14 +148,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 +241,7 @@ export function DanmakuStage({
return () => {
disposed = true
ro.disconnect()
cancelAnimationFrame(raf)
video.removeEventListener('play', onPlay)
video.removeEventListener('playing', onPlay)
+14 -11
View File
@@ -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>
+10
View File
@@ -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">
+202 -36
View File
@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import {
Captions,
CaptionsOff,
Maximize,
MessageSquareText,
Minimize,
@@ -9,16 +11,12 @@ import {
Volume2,
VolumeX,
} from 'lucide-react'
import type { SubtitleTrack } from '../api/subtitles'
// PlayerControls — custom bottom control bar replacing the native <video
// controls> (which cannot host custom buttons). The danmaku toggle sits right
// next to the volume control. The bar auto-hides while playing and reappears
// on mouse movement; it stays visible while paused.
//
// Native keyboard shortcuts (space / arrows) still work because they are
// element-level defaults on <video>. Subtitles from <track> elements keep
// rendering (the default track shows as before); only the track picker UI is
// not re-implemented here.
// on mouse movement; it stays visible while paused or when hovering/interacting.
function formatTime(s: number): string {
if (!Number.isFinite(s) || s < 0) s = 0
@@ -29,6 +27,10 @@ function formatTime(s: number): string {
type PlayerControlsProps = {
videoRef: React.RefObject<HTMLVideoElement>
subs: SubtitleTrack[]
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
subtitleIndex: number
onSelectSubtitle: (index: number) => void
danmakuOpen: boolean
danmakuEnabled: boolean
onToggleDanmaku: () => void
@@ -36,12 +38,18 @@ type PlayerControlsProps = {
export function PlayerControls({
videoRef,
subs,
subtitleIndex,
onSelectSubtitle,
danmakuOpen,
danmakuEnabled,
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)
@@ -51,44 +59,113 @@ export function PlayerControls({
const [fullscreen, setFullscreen] = useState(false)
const [pip, setPip] = useState(false)
const [uiVisible, setUiVisible] = useState(true)
const [controlsHovered, setControlsHovered] = useState(false)
const [isScrubbing, setIsScrubbing] = useState(false)
const [scrubValue, setScrubValue] = useState<number | null>(null)
const [subtitleMenuOpen, setSubtitleMenuOpen] = useState(false)
const subtitleMenuRef = 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 danmakuOpenRef = useRef(false)
// 播放时 3 秒无操作自动隐藏控制栏;暂停时保持显示。监听挂在视频容器上,
// 控制栏隐藏(pointer-events-none)后移动鼠标仍能重新唤起。
useEffect(() => {
const el = video()
if (!el) return
const parent = el.parentElement
const onMove = () => {
setUiVisible(true)
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
if (!el.paused) {
hideTimerRef.current = setTimeout(() => setUiVisible(false), 3000)
controlsHoveredRef.current = controlsHovered
}, [controlsHovered])
useEffect(() => {
isScrubbingRef.current = isScrubbing
}, [isScrubbing])
useEffect(() => {
subtitleMenuOpenRef.current = subtitleMenuOpen
}, [subtitleMenuOpen])
useEffect(() => {
danmakuOpenRef.current = danmakuOpen
}, [danmakuOpen])
// 点击控制栏外部时关闭字幕菜单
useEffect(() => {
if (!subtitleMenuOpen) return
const onDocClick = (e: MouseEvent) => {
if (subtitleMenuRef.current && !subtitleMenuRef.current.contains(e.target as Node)) {
setSubtitleMenuOpen(false)
}
}
const onLeave = () => {
if (el.paused) return
document.addEventListener('mousedown', onDocClick)
return () => document.removeEventListener('mousedown', onDocClick)
}, [subtitleMenuOpen])
// 播放时 3 秒无操作自动隐藏控制栏;暂停/悬停/拖动进度条/打开菜单时保持显示。
// 监听挂在整个播放器舞台容器(data-player-stage)上,避免光标移到控制栏时因离开视频画面而误触发 mouseleave。
useEffect(() => {
const el = video()
const stage = container()
if (!el || !stage) return
const resetTimer = () => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
if (
!el.paused &&
!controlsHoveredRef.current &&
!isScrubbingRef.current &&
!subtitleMenuOpenRef.current &&
!danmakuOpenRef.current
) {
hideTimerRef.current = setTimeout(() => {
if (
!controlsHoveredRef.current &&
!isScrubbingRef.current &&
!subtitleMenuOpenRef.current &&
!danmakuOpenRef.current
) {
setUiVisible(false)
}
}, 3000)
}
}
const onMove = () => {
setUiVisible(true)
resetTimer()
}
const onLeave = (e: MouseEvent) => {
// 仅当光标真正移出 stage 容器时才处理
if (e.relatedTarget && stage.contains(e.relatedTarget as Node)) {
return
}
if (el.paused || controlsHoveredRef.current || isScrubbingRef.current) return
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
setUiVisible(false)
}
const syncPlay = () => {
setPlaying(!el.paused)
onMove()
}
const syncTime = () => setCurrentTime(el.currentTime)
const syncTime = () => {
if (!isScrubbingRef.current) {
setCurrentTime(el.currentTime)
}
}
const syncMeta = () => {
setDuration(el.duration || 0)
setCurrentTime(el.currentTime)
if (!isScrubbingRef.current) {
setCurrentTime(el.currentTime)
}
}
const syncVolume = () => {
setVolume(el.volume)
setMuted(el.muted)
}
const syncFullscreen = () => setFullscreen(document.fullscreenElement === parent)
const syncFullscreen = () => setFullscreen(Boolean(document.fullscreenElement))
const syncPip = () => setPip(document.pictureInPictureElement === el)
parent?.addEventListener('mousemove', onMove)
parent?.addEventListener('mouseleave', onLeave)
stage.addEventListener('mousemove', onMove)
stage.addEventListener('mouseleave', onLeave)
el.addEventListener('play', syncPlay)
el.addEventListener('playing', syncPlay)
@@ -105,8 +182,8 @@ export function PlayerControls({
syncVolume()
syncFullscreen()
return () => {
parent?.removeEventListener('mousemove', onMove)
parent?.removeEventListener('mouseleave', onLeave)
stage.removeEventListener('mousemove', onMove)
stage.removeEventListener('mouseleave', onLeave)
el.removeEventListener('play', syncPlay)
el.removeEventListener('playing', syncPlay)
el.removeEventListener('pause', syncPlay)
@@ -122,6 +199,20 @@ export function PlayerControls({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoRef])
// 当悬停或菜单状态改变时,更新控制栏计时器
useEffect(() => {
if (controlsHovered || isScrubbing || subtitleMenuOpen || danmakuOpen) {
setUiVisible(true)
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
} else {
const el = video()
if (el && !el.paused) {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
hideTimerRef.current = setTimeout(() => setUiVisible(false), 3000)
}
}
}, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen])
const togglePlay = () => {
const el = video()
if (!el) return
@@ -129,11 +220,29 @@ export function PlayerControls({
else el.pause()
}
const seek = (v: number) => {
const el = video()
if (!el) return
el.currentTime = v
const handleSeekChange = (v: number) => {
setScrubValue(v)
setCurrentTime(v)
const el = video()
if (el && !isScrubbing) {
el.currentTime = v
}
}
const handleSeekStart = () => {
setIsScrubbing(true)
setUiVisible(true)
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
}
const handleSeekEnd = (v: number) => {
const el = video()
if (el) {
el.currentTime = v
setCurrentTime(v)
}
setIsScrubbing(false)
setScrubValue(null)
}
const changeVolume = (v: number) => {
@@ -176,14 +285,18 @@ export function PlayerControls({
'pictureInPictureEnabled' in document &&
document.pictureInPictureEnabled
const displayTime = isScrubbing && scrubValue !== null ? scrubValue : currentTime
return (
<div
className={`pointer-events-auto absolute inset-x-0 bottom-0 z-20 px-3 pb-2 pt-12 transition-opacity duration-300 ${
className={`pointer-events-auto absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-3 pb-3 pt-14 transition-opacity duration-300 ${
uiVisible ? 'opacity-100' : 'pointer-events-none opacity-0'
}`}
onMouseEnter={() => setControlsHovered(true)}
onMouseLeave={() => setControlsHovered(false)}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 text-white">
<div className="flex items-center gap-2.5 text-white">
<button
onClick={togglePlay}
className="rounded-full p-1.5 transition hover:bg-white/15"
@@ -197,14 +310,18 @@ export function PlayerControls({
min={0}
max={duration || 0}
step={0.1}
value={currentTime}
onChange={(e) => seek(Number(e.target.value))}
className="min-w-0 flex-1 accent-rose-500"
value={displayTime}
onMouseDown={handleSeekStart}
onTouchStart={handleSeekStart}
onChange={(e) => handleSeekChange(Number(e.target.value))}
onMouseUp={(e) => handleSeekEnd(Number((e.target as HTMLInputElement).value))}
onTouchEnd={(e) => handleSeekEnd(Number((e.target as HTMLInputElement).value))}
className="min-w-0 flex-1 cursor-pointer accent-rose-500"
aria-label="播放进度"
/>
<span className="shrink-0 font-mono text-xs tabular-nums text-white/85">
{formatTime(currentTime)} / {formatTime(duration)}
{formatTime(displayTime)} / {formatTime(duration)}
</span>
{pipSupported && (
@@ -217,6 +334,55 @@ export function PlayerControls({
</button>
)}
{subs.length > 0 && (
<div className="relative" ref={subtitleMenuRef}>
<button
onClick={() => setSubtitleMenuOpen((v) => !v)}
className="rounded-full p-1.5 transition hover:bg-white/15"
title="字幕"
>
{subtitleIndex >= 0 ? (
<Captions size={18} className="text-rose-400" />
) : (
<CaptionsOff size={18} className="text-white/70" />
)}
</button>
{subtitleMenuOpen && (
<div className="absolute bottom-11 right-0 z-30 min-w-36 rounded-xl border border-white/15 bg-black/85 p-1 shadow-2xl backdrop-blur">
<button
type="button"
onClick={() => {
onSelectSubtitle(-1)
setSubtitleMenuOpen(false)
}}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs transition hover:bg-white/10 ${
subtitleIndex < 0 ? 'text-rose-400' : 'text-white/85'
}`}
>
关闭字幕
</button>
{subs.map((track, index) => (
<button
key={track.path}
type="button"
onClick={() => {
onSelectSubtitle(index)
setSubtitleMenuOpen(false)
}}
className={`flex w-full items-center gap-2 truncate rounded-lg px-3 py-1.5 text-left text-xs transition hover:bg-white/10 ${
subtitleIndex === index ? 'text-rose-400' : 'text-white/85'
}`}
title={track.label || track.lang}
>
<span className="truncate">{track.label || track.lang || `字幕 ${index + 1}`}</span>
{subtitleIndex === index && <span className="ml-auto text-rose-400">●</span>}
</button>
))}
</div>
)}
</div>
)}
<button
onClick={onToggleDanmaku}
className={
+118 -43
View File
@@ -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>
+23
View File
@@ -326,3 +326,26 @@ body {
background-color: var(--app-brand-emphasis) !important;
color: var(--app-brand-text) !important;
}
/* ── 播放器字幕(WebVTT <track>)──
彻底去除浏览器 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 !important;
font-weight: 500 !important;
line-height: 1.35 !important;
text-shadow:
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;
}
+18
View File
@@ -59,8 +59,26 @@ export function HomePage() {
})
// Fetch media items for all libraries in parallel
const isSeriesType = (type?: string) => type === 'tv' || type === 'anime' || type === 'variety'
const results = await Promise.allSettled(
libs.map(async (lib) => {
// 剧集类媒体库(tv/anime/variety):后端 /series 已按剧聚合,
// 首页若用 episode 级 /media 的前 30 行再 groupSeries,同一部剧的
// 多集会折叠成 1 张卡,导致整行只显示 1 个条目。
// 改用 /series 分页拉取全部聚合后的剧集卡片。
if (isSeriesType(lib.type)) {
const cards: SeriesCard[] = []
let total = 0
const pageSize = 200
for (let page = 1; page <= 10; page++) {
const data = await libraryAPI.listSeries(lib.id, page, pageSize)
const pageItems = asArray<SeriesCard>(data?.items)
cards.push(...pageItems)
total = data?.total ?? cards.length
if (cards.length >= total || pageItems.length < pageSize) break
}
return { id: lib.id, cards, items: [], total }
}
const page = await libraryAPI.listMedia(lib.id, 1, 30)
const items = asArray<Media>(page?.items)
const cards = groupSeries(items)
+36 -30
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { libraryAPI } from '../api/library'
import { toolsAPI } from '../api/tools'
import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
import {
LibrariesContent,
LibrariesEmptyState,
@@ -16,6 +17,32 @@ export function LibrariesPage() {
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useState(false)
const [repairMsg, setRepairMsg] = useState('')
const loadLibraries = useCallback(async () => {
setLoading(true)
try {
const libs = await libraryAPI.list()
const rows = await Promise.all(libs.map(async (library) => {
try {
if (isSeriesLibraryType(library.type)) {
const [seriesPage, mediaPage] = await Promise.all([
libraryAPI.listSeries(library.id, 1, 10),
libraryAPI.listMedia(library.id, 1, 1, { groupVersions: false }),
])
return { library, items: [], total: mediaPage.total, cards: seriesPage.items ?? [] } satisfies LibraryPreview
}
const page = await libraryAPI.listMedia(library.id, 1, 160, { groupVersions: false })
const cards = latestLibraryCards(page.items)
return { library, items: page.items, total: page.total, cards } satisfies LibraryPreview
} catch {
return { library, items: [], total: 0, cards: [] } satisfies LibraryPreview
}
}))
setPreviews(rows)
} finally {
setLoading(false)
}
}, [])
async function handleRepairRescrape() {
if (repairing) return
setRepairing(true)
@@ -30,36 +57,14 @@ export function LibrariesPage() {
}
}
const handleManageLibraries = async () => {
await openManageLibrariesDialog()
await loadLibraries()
}
useEffect(() => {
let cancelled = false
async function load() {
setLoading(true)
try {
const libs = await libraryAPI.list()
const rows = await Promise.all(libs.map(async (library) => {
try {
if (isSeriesLibraryType(library.type)) {
const [seriesPage, mediaPage] = await Promise.all([
libraryAPI.listSeries(library.id, 1, 10),
libraryAPI.listMedia(library.id, 1, 1, { groupVersions: false }),
])
return { library, items: [], total: mediaPage.total, cards: seriesPage.items ?? [] } satisfies LibraryPreview
}
const page = await libraryAPI.listMedia(library.id, 1, 160, { groupVersions: false })
const cards = latestLibraryCards(page.items)
return { library, items: page.items, total: page.total, cards } satisfies LibraryPreview
} catch {
return { library, items: [], total: 0, cards: [] } satisfies LibraryPreview
}
}))
if (!cancelled) setPreviews(rows)
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => { cancelled = true }
}, [])
loadLibraries().catch(() => undefined)
}, [loadLibraries])
const total = useMemo(() => previews.reduce((sum, preview) => sum + preview.total, 0), [previews])
@@ -77,6 +82,7 @@ export function LibrariesPage() {
repairing={repairing}
onRepairEpisodeArtworkChange={setRepairEpisodeArtwork}
onRepairRescrape={handleRepairRescrape}
onManageLibraries={handleManageLibraries}
/>
{previews.length === 0 ? (
+3 -2
View File
@@ -6,7 +6,6 @@ import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, PlayCircle
import { imageURL } from '../api/client'
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
import { MediaCard } from '../components/MediaCard'
import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
import { seriesCardLink } from '../utils/groupSeries'
import { libraryDisplayPath } from './libraryDisplayModel'
import { libraryArtworkItems, type LibraryPreview } from './librariesPageModel'
@@ -37,6 +36,7 @@ export function LibrariesHeader({
repairing,
onRepairEpisodeArtworkChange,
onRepairRescrape,
onManageLibraries,
}: {
previewCount: number
total: number
@@ -45,6 +45,7 @@ export function LibrariesHeader({
repairing: boolean
onRepairEpisodeArtworkChange: (value: boolean) => void
onRepairRescrape: () => void
onManageLibraries: () => void
}) {
return (
<div className="flex flex-wrap items-end justify-between gap-4">
@@ -72,7 +73,7 @@ export function LibrariesHeader({
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
{repairing ? '正在启动…' : '全库修复+重刮'}
</button>
<button type="button" onClick={() => openManageLibrariesDialog()} className="btn-outline">
<button type="button" onClick={onManageLibraries} className="btn-outline">
管理媒体库
</button>
</div>
+60 -5
View File
@@ -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'
@@ -27,6 +27,23 @@ import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
//
// External subtitles next to the source file are auto-discovered and
// attached as <track> elements.
const SUBTITLE_STORAGE_KEY = 'mmtl.subtitle'
// 初始字幕偏好:localStorage 记录上次选择的轨道(-1=关闭);没有偏好时
// 默认 0(自动加载第一条字幕)。
function initialSubtitleIndex(): number {
try {
const saved = localStorage.getItem(SUBTITLE_STORAGE_KEY)
if (saved !== null && saved !== '') {
const n = parseInt(saved, 10)
if (Number.isFinite(n)) return n
}
} catch {
// ignore
}
return 0
}
export function PlayerPage() {
const { id = '' } = useParams()
const [params, setParams] = useSearchParams()
@@ -40,6 +57,7 @@ export function PlayerPage() {
const [media, setMedia] = useState<Media | null>(null)
const [mode, setMode] = useState<PlayerMode>('direct')
const [subs, setSubs] = useState<SubtitleTrack[]>([])
const [subtitleIndex, setSubtitleIndex] = useState<number>(initialSubtitleIndex)
const [hlsUnavailable, setHlsUnavailable] = useState(false)
const [playerError, setPlayerError] = useState('')
// 「客户端直连解码」模式:宿主机不转码,播放器强制 direct play、隐藏 HLS 切换。
@@ -54,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)
@@ -111,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)
}, [])
@@ -132,7 +155,7 @@ export function PlayerPage() {
setDanmakuCandidates([])
setDanmakuSearching(true)
// 展示当前所选来源(面板标题处可见)。
setDanmakuSelectedSource(`${animeTitle}・${episodeTitle}`)
setDanmakuSelectedSource(episodeTitle ? `${animeTitle}・${episodeTitle}` : animeTitle)
}, [])
// 回到自动匹配(清除用户手动选择)。
@@ -141,8 +164,21 @@ export function PlayerPage() {
setDanmakuCandidates([])
setDanmakuSearching(true)
setDanmakuSearch(null)
setDanmakuSelectedSource('')
setDanmakuInfo(null)
}, [])
// 切换视频时重置媒体与弹幕状态,确保新视频自动重新识别并加载弹幕
useEffect(() => {
setMedia(null)
setDanmakuEpisodeId(null)
setDanmakuCandidates([])
setDanmakuSearch(null)
setDanmakuSelectedSource('')
setDanmakuInfo(null)
setDanmakuSearching(true)
}, [id])
// Load metadata and pick a default mode.
useEffect(() => {
if (!id) return
@@ -156,7 +192,13 @@ export function PlayerPage() {
})
subtitlesAPI
.list(id)
.then((tracks) => setSubs(tracks ?? []))
.then((tracks) => {
const list = tracks ?? []
setSubs(list)
// 记忆的轨道下标可能超出当前媒体的轨道数(不同媒体字幕数量不同),
// 越界时回退到第一条;无字幕则关闭。
setSubtitleIndex((cur) => (cur >= list.length ? (list.length > 0 ? 0 : -1) : cur))
})
.catch(() => setSubs([]))
}, [id, params, directOnly])
@@ -246,6 +288,16 @@ export function PlayerPage() {
setParams(params, { replace: true })
}, [mode, params, setParams])
// 用户切换字幕轨道:-1=关闭;记忆偏好,下次播放默认沿用。
const selectSubtitle = useCallback((index: number) => {
setSubtitleIndex(index)
try {
localStorage.setItem(SUBTITLE_STORAGE_KEY, String(index))
} catch {
// ignore
}
}, [])
const handleVideoError = useCallback(() => {
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
// 转给开发者控制台 + 一条 toast;常见原因是 codec 不支持。
@@ -270,7 +322,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}
@@ -281,6 +333,8 @@ export function PlayerPage() {
media={media}
playerError={playerError}
subs={subs}
subtitleIndex={subtitleIndex}
onSelectSubtitle={selectSubtitle}
videoRef={ref}
onVideoError={handleVideoError}
danmakuEnabled={danmakuEnabled}
@@ -310,6 +364,7 @@ export function PlayerPage() {
onFontSizeChange={setDanmakuFontSize}
candidates={danmakuCandidates}
selectedSource={danmakuSelectedSource}
danmakuInfo={danmakuInfo}
onSelectEpisode={danmakuSelectEpisode}
onResetAuto={danmakuResetAuto}
/>
+215 -34
View File
@@ -1,7 +1,8 @@
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'
@@ -10,6 +11,9 @@ type PlayerVideoStageProps = {
media: Media | null
playerError: string
subs: SubtitleTrack[]
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
subtitleIndex: number
onSelectSubtitle: (index: number) => void
videoRef: RefObject<HTMLVideoElement>
onVideoError: () => void
danmakuEnabled: boolean
@@ -20,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
@@ -30,6 +34,8 @@ export function PlayerVideoStage({
media,
playerError,
subs,
subtitleIndex,
onSelectSubtitle,
videoRef,
onVideoError,
danmakuEnabled,
@@ -44,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
@@ -52,52 +99,186 @@ 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?.()
}
// 自定义字幕驱动逻辑:
// 把所选轨道设为 mode = 'hidden'(让浏览器在后台静默解析时间轴,但不渲染原生带黑底的字幕框),
// 由下方的 React 自定义层输出 100% 纯透明背景、高清晰文字阴影的字幕。
useEffect(() => {
const video = videoRef.current
if (!video || subs.length === 0 || subtitleIndex < 0 || !subs[subtitleIndex]) {
setActiveCueText('')
return
}
const updateCue = () => {
const trackEls = Array.from(video.querySelectorAll('track'))
const selectedEl = trackEls[subtitleIndex]
const tt = selectedEl?.track
if (!tt) {
setActiveCueText('')
return
}
// 优先从浏览器 activeCues 中取当前文本;若浏览器在 hidden 模式下延迟触发 cuechange,
// 则从 tt.cues 中根据 video.currentTime 实时匹配当前字幕,确保初次加载无感立即可见。
const texts: string[] = []
if (tt.activeCues && tt.activeCues.length > 0) {
for (let i = 0; i < tt.activeCues.length; i++) {
const cue = tt.activeCues[i] as VTTCue
if (cue && cue.text) texts.push(cue.text)
}
} else if (tt.cues && tt.cues.length > 0) {
const cur = video.currentTime
for (let i = 0; i < tt.cues.length; i++) {
const cue = tt.cues[i] as VTTCue
if (cue && cur >= cue.startTime && cur <= cue.endTime && cue.text) {
texts.push(cue.text)
}
}
}
setActiveCueText(texts.join('\n'))
}
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)
}
selected.removeEventListener('load', updateCue)
selected.addEventListener('load', updateCue)
updateCue()
}
apply()
video.addEventListener('loadedmetadata', apply)
video.addEventListener('timeupdate', updateCue)
video.addEventListener('seeking', updateCue)
video.addEventListener('seeked', updateCue)
video.addEventListener('playing', updateCue)
return () => {
video.removeEventListener('loadedmetadata', apply)
video.removeEventListener('timeupdate', updateCue)
video.removeEventListener('seeking', updateCue)
video.removeEventListener('seeked', updateCue)
video.removeEventListener('playing', updateCue)
const trackEls = Array.from(video.querySelectorAll('track'))
const selected = trackEls[subtitleIndex]
if (selected) {
selected.removeEventListener('load', updateCue)
if (selected.track) {
selected.track.removeEventListener('cuechange', updateCue)
}
}
}
}, [subtitleIndex, subs, videoRef, media])
// 根据视频画面宽高比与舞台宽高比,确定视频在哪个轴向撑满 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, index) => (
<track
key={track.path}
kind="subtitles"
src={subtitlesAPI.url(media.id, track.path)}
srcLang={track.lang}
label={track.label || track.lang}
default={index === 0}
/>
))}
</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, index) => (
<track
key={track.path}
kind="subtitles"
src={subtitlesAPI.url(media.id, track.path)}
srcLang={track.lang}
label={track.label || track.lang}
default={index === subtitleIndex}
/>
))}
</video>
<DanmakuStage
key={media.id}
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}
subtitleIndex={subtitleIndex}
onSelectSubtitle={onSelectSubtitle}
danmakuOpen={danmakuOpen}
danmakuEnabled={danmakuEnabled}
onToggleDanmaku={onToggleDanmaku}
@@ -114,4 +295,4 @@ export function PlayerVideoStage({
) : null}
</div>
)
}
}
+54 -7
View File
@@ -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)
}
+9 -2
View File
@@ -674,6 +674,7 @@ export function StrmSyncPathDialog({
delete_dir: existing?.delete_dir ?? false,
cron: existing?.cron ?? '',
enable_cron: existing?.enable_cron ?? false,
sync_mode: existing?.sync_mode ?? 'incremental',
enabled: existing?.enabled ?? true,
}))
const [saving, setSaving] = useState(false)
@@ -831,7 +832,7 @@ export function StrmSyncPathDialog({
<input className={inputCls} value={form.exclude_name ?? ''} placeholder="sample,trailer" onChange={(e) => set('exclude_name', e.target.value)} />
</Field>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-3 md:grid-cols-3">
<Field label="STRM 链接 path 参数">
<select className={inputCls} value={form.add_path ?? 1} onChange={(e) => set('add_path', Number(e.target.value))}>
<option value={1}>完整远端路径</option>
@@ -839,7 +840,13 @@ export function StrmSyncPathDialog({
<option value={3}>不带 path</option>
</select>
</Field>
<Field label="定时同步 Cron" hint="5 段表达式,如 0 */6 * * *(每 6 小时)">
<Field label="默认同步模式" hint="定时触发或快速同步时的策略">
<select className={inputCls} value={form.sync_mode ?? 'incremental'} onChange={(e) => set('sync_mode', e.target.value as 'incremental' | 'full')}>
<option value="incremental">增量同步(快速)</option>
<option value="full">全量同步(全量校验)</option>
</select>
</Field>
<Field label="定时同步 Cron" hint="5 段表达式,如 0 */6 * * *">
<input className={inputCls} value={form.cron ?? ''} placeholder="0 */6 * * *" onChange={(e) => set('cron', e.target.value)} />
</Field>
</div>
+36 -14
View File
@@ -96,11 +96,11 @@ export function StrmManagePage() {
return () => clearInterval(timer)
}, [paths, refresh])
const startSync = async (path: StrmSyncPath) => {
const startSync = async (path: StrmSyncPath, mode: 'incremental' | 'full' = 'incremental') => {
setActingPath(path.id)
try {
await strmAPI.startSync(path.id)
toast.success(`已开始同步「${path.name}」`)
await strmAPI.startSync(path.id, mode)
toast.success(`已开始${mode === 'full' ? '全量' : '增量'}同步「${path.name}」`)
await refresh()
} catch (err) {
toast.error(apiErrorMessage(err))
@@ -344,7 +344,7 @@ function SyncPathSection({
onAdd: () => void
onEdit: (path: StrmSyncPath) => void
onDelete: (path: StrmSyncPath) => void
onStart: (path: StrmSyncPath) => void
onStart: (path: StrmSyncPath, mode?: 'incremental' | 'full') => void
onCancel: (path: StrmSyncPath) => void
}) {
return (
@@ -401,15 +401,28 @@ function SyncPathSection({
取消
</button>
) : (
<button
type="button"
disabled={actingPath === path.id || !path.enabled}
onClick={() => onStart(path)}
className={`${iconButtonCls} disabled:opacity-40`}
>
{actingPath === path.id ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
立即同步
</button>
<>
<button
type="button"
disabled={actingPath === path.id || !path.enabled}
onClick={() => onStart(path, 'incremental')}
className={`${iconButtonCls} text-brand-600 font-medium disabled:opacity-40`}
title="增量同步:基于目录缓存快速同步新增与更新文件"
>
{actingPath === path.id ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
增量同步
</button>
<button
type="button"
disabled={actingPath === path.id || !path.enabled}
onClick={() => onStart(path, 'full')}
className={`${iconButtonCls} text-sand-600 disabled:opacity-40`}
title="全量同步:重置目录缓存并全量比对所有文件"
>
<RefreshCw size={14} />
全量同步
</button>
</>
)}
<button type="button" onClick={() => onEdit(path)} className={`${iconButtonCls}`}>
<Pencil size={14} />
@@ -456,9 +469,11 @@ function RecordSection({ records }: { records: StrmSyncRecord[] }) {
<thead className="border-b border-gray-200 text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="px-3 py-2">时间</th>
<th className="px-3 py-2">类型</th>
<th className="px-3 py-2">状态</th>
<th className="px-3 py-2 text-right">扫描文件</th>
<th className="px-3 py-2 text-right">新增 strm</th>
<th className="px-3 py-2 text-right">新增/更新</th>
<th className="px-3 py-2 text-right">跳过</th>
<th className="px-3 py-2 text-right">下载元数据</th>
<th className="px-3 py-2 text-right">清理</th>
<th className="px-3 py-2">说明</th>
@@ -467,11 +482,17 @@ function RecordSection({ records }: { records: StrmSyncRecord[] }) {
<tbody>
{records.map((record) => {
const meta = RECORD_STATUS_META[record.status] ?? RECORD_STATUS_META.pending
const isFull = record.sync_type === 'full'
return (
<tr key={record.id} className="border-t border-gray-100">
<td className="whitespace-nowrap px-3 py-2 text-xs text-ink-50">
{formatTime(record.started_at ?? record.created_at)}
</td>
<td className="px-3 py-2">
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${isFull ? 'bg-amber-50 text-amber-600 border border-amber-200' : 'bg-brand-50 text-brand-600 border border-brand-200'}`}>
{isFull ? '全量' : '增量'}
</span>
</td>
<td className="px-3 py-2">
<span className={'rounded-full px-2 py-0.5 text-[11px] font-semibold ' + meta.cls}>
{meta.label}
@@ -479,6 +500,7 @@ function RecordSection({ records }: { records: StrmSyncRecord[] }) {
</td>
<td className="px-3 py-2 text-right">{record.total}</td>
<td className="px-3 py-2 text-right text-brand-500">{record.new_strm}</td>
<td className="px-3 py-2 text-right text-gray-500">{record.skipped}</td>
<td className="px-3 py-2 text-right">{record.new_meta}</td>
<td className="px-3 py-2 text-right">{record.pruned}</td>
<td className="max-w-[260px] truncate px-3 py-2 text-xs text-sand-500">{record.message}</td>
+4
View File
@@ -12,6 +12,10 @@ export function pickPlayerMode(media: Media): PlayerMode {
export function needsTranscodeForBrowser(media: Media): boolean {
const container = (media.container ?? '').toLowerCase()
// .strm 媒体内容是远程直链(服务端 302 到播放 CDN 或反向代理),
// 浏览器直接播放该远程流即可,转码无意义且必然失败(ffmpeg 无法读取
// 文本 strm),恒走 direct play。
if (container.includes('strm') || String(media.strm_url ?? '').trim() !== '') return false
const videoCodec = (media.video_codec ?? '').toLowerCase()
const audioCodec = (media.audio_codec ?? '').toLowerCase()
const containerOK = directContainers.some((item) => container.includes(item))
+14
View File
@@ -22,6 +22,20 @@ export const danmakuSettingsGroup: SettingGroup = {
hint: '留空使用官方 https://api.dandanplay.net。可填写自建或第三方符合 dandanplay 协议的服务地址(含 /api/v2/search/episodes 搜索与 /api/v2/comment/:id 弹幕接口)。播放时按视频名称搜索番剧并拉取 Bilibili 格式 XML 弹幕。',
placeholder: 'https://api.dandanplay.net',
},
{
key: 'danmaku.app_id',
label: 'AppId(弹弹play 开放 API)',
type: 'text',
hint: '弹弹play DevCenter 申请的应用 ID(https://doc.dandanplay.com/open/)。官方接口要求应用认证,留空使用内置凭据(签名认证,开箱即用);填写自己的 AppId/AppKey 可覆盖内置凭据。仅对官方 api.dandanplay.net 生效,第三方协议源不会携带凭据。',
placeholder: '在 DevCenter 申请的应用 ID',
},
{
key: 'danmaku.app_key',
label: 'AppKey(弹弹play 应用密钥)',
type: 'text',
hint: '与 AppId 配套的 AppSecret,只保存在服务器上用于计算请求签名(base64(sha256(AppId+Timestamp+Path+Secret))),不会下发到播放器。',
placeholder: '在 DevCenter 申请的应用密钥',
},
{
key: 'danmaku.opacity',
label: '弹幕透明度',
+35
View File
@@ -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',
},
],
}
+3
View File
@@ -49,6 +49,7 @@ export interface StrmSyncPath {
delete_dir: boolean
cron: string
enable_cron: boolean
sync_mode?: 'incremental' | 'full'
enabled: boolean
created_at: string
last_sync_at?: string | null
@@ -75,12 +76,14 @@ export interface StrmSyncPathInput {
delete_dir?: boolean
cron?: string
enable_cron?: boolean
sync_mode?: 'incremental' | 'full'
enabled?: boolean
}
export interface StrmSyncRecord {
id: string
sync_path_id: string
sync_type?: 'incremental' | 'full'
status: 'pending' | 'running' | 'done' | 'failed' | 'canceled'
total: number
new_strm: number