Compare commits

...

37 Commits

Author SHA1 Message Date
github-actions[bot] 0332579d5f chore: bump version to 0.0.34 [skip ci] 2026-08-26 04:52:28 +00:00
truewhile 6aefe18caa 5 2026-08-26 12:52:10 +08:00
github-actions[bot] ef72fc8d83 chore: bump version to 0.0.33 [skip ci] 2026-08-26 04:12:13 +00:00
truewhile 4764c09572 4 2026-08-26 12:11:56 +08:00
github-actions[bot] 98ca766a37 chore: bump version to 0.0.32 [skip ci] 2026-08-26 03:38:14 +00:00
truewhile 13c9035b76 3 2026-08-26 11:37:58 +08:00
github-actions[bot] ad6d0ba21d chore: bump version to 0.0.31 [skip ci] 2026-08-26 03:19:09 +00:00
truewhile 431f7f088b 2 2026-08-26 11:18:53 +08:00
github-actions[bot] 3f13ed1113 chore: bump version to 0.0.30 [skip ci] 2026-08-26 01:51:54 +00:00
truewhile 9d359c40dd 1 2026-08-26 09:51:27 +08:00
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
72 changed files with 3222 additions and 719 deletions
+1 -1
View File
@@ -1 +1 @@
0.0.18
0.0.34
+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")
}
}
+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"`
+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)) {
+3
View File
@@ -50,11 +50,14 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.POST("/strm/downloads/:id/retry", retryStrmDownloadHandler(svc))
admin.POST("/strm/downloads/clear-done", clearDoneDownloadsHandler(svc))
admin.POST("/strm/downloads/clear-finished", clearFinishedDownloadsHandler(svc))
admin.POST("/strm/downloads/clear-canceled", clearCanceledDownloadsHandler(svc))
admin.POST("/strm/downloads/retry-failed", retryAllFailedDownloadsHandler(svc))
admin.POST("/strm/downloads/cancel-pending", cancelPendingDownloadsHandler(svc))
admin.GET("/strm/uploads", uploadQueueHandler(svc))
admin.POST("/strm/uploads/:id/cancel", cancelStrmUploadHandler(svc))
admin.POST("/strm/uploads/:id/retry", retryStrmUploadHandler(svc))
admin.POST("/strm/uploads/cancel-pending", cancelPendingUploadsHandler(svc))
admin.POST("/strm/uploads/clear-canceled", clearCanceledUploadsHandler(svc))
}
func registerAdminUserRoutes(admin *gin.RouterGroup, svc *service.Container) {
+45 -1
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
}
@@ -382,6 +392,28 @@ func clearFinishedDownloadsHandler(svc *service.Container) gin.HandlerFunc {
}
}
func clearCanceledDownloadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.ClearCanceledDownloadTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
}
}
func clearCanceledUploadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.ClearCanceledUploadTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
}
}
func retryAllFailedDownloadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.RetryAllFailedDownloadTasks(c.Request.Context())
@@ -404,6 +436,17 @@ func cancelPendingDownloadsHandler(svc *service.Container) gin.HandlerFunc {
}
}
func cancelPendingUploadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.CancelPendingUploadTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"canceled": n})
}
}
// ─── 公开播放端点 ──────────────────────────────────────────────────────────────
// strmPlayHandler 处理 strm 文件指向的播放请求(Emby/Infuse 直接请求,无 JWT)。
@@ -458,6 +501,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 -4
View File
@@ -50,10 +50,16 @@ func TestStrmAdminRoutesAreRegistered(t *testing.T) {
"GET /api/admin/strm/downloads",
"POST /api/admin/strm/downloads/:id/cancel",
"POST /api/admin/strm/downloads/:id/retry",
"GET /api/admin/strm/uploads",
"POST /api/admin/strm/uploads/:id/cancel",
"POST /api/admin/strm/uploads/:id/retry",
"GET /api/strm/play/:provider/:file",
"POST /api/admin/strm/downloads/clear-finished",
"POST /api/admin/strm/downloads/clear-canceled",
"POST /api/admin/strm/downloads/retry-failed",
"POST /api/admin/strm/downloads/cancel-pending",
"GET /api/admin/strm/uploads",
"POST /api/admin/strm/uploads/:id/cancel",
"POST /api/admin/strm/uploads/:id/retry",
"POST /api/admin/strm/uploads/cancel-pending",
"POST /api/admin/strm/uploads/clear-canceled",
"GET /api/strm/play/:provider/:file",
} {
if !routes[want] {
t.Fatalf("%s route is not registered", want)
+1 -1
View File
@@ -163,7 +163,7 @@ func historyDeleteHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "status must be completed or incomplete"})
return
}
res := q.Delete(&model.PlaybackHistory{})
res := q.Unscoped().Delete(&model.PlaybackHistory{})
if err := res.Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
+4 -3
View File
@@ -54,7 +54,8 @@ func AllModels() []interface{} {
&StrmAccount{},
&StrmSyncPath{},
&StrmSyncRecord{},
&StrmDownloadTask{},
&StrmUploadTask{},
}
&StrmDownloadTask{},
&StrmUploadTask{},
&StrmDirCache{},
}
}
+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 -2
View File
@@ -62,9 +62,9 @@ func (r *ApiConfigRepository) Update(ctx context.Context, c *model.ApiConfig) er
}).Error
}
// Delete removes an API config.
// Delete 物理删除 API 配置。
func (r *ApiConfigRepository) Delete(ctx context.Context, provider string) error {
return r.db.WithContext(ctx).Where("provider = ?", provider).Delete(&model.ApiConfig{}).Error
return r.db.WithContext(ctx).Unscoped().Where("provider = ?", provider).Delete(&model.ApiConfig{}).Error
}
// UpdateTestResult 更新测试结果。
+1 -1
View File
@@ -23,7 +23,7 @@ func (r *FavoriteRepository) Toggle(ctx context.Context, userID, mediaID string)
if err != nil {
return false, err
}
return false, r.db.WithContext(ctx).Delete(&f).Error
return false, r.db.WithContext(ctx).Unscoped().Delete(&f).Error
}
// ListByUser returns all favourite media IDs for a user.
+3 -4
View File
@@ -79,10 +79,9 @@ func (r *LibraryRepository) FindByID(ctx context.Context, id string) (*model.Lib
return &l, nil
}
// Delete removes a library and (soft) cascades to its media via repository
// callers; we do not run CASCADE here to keep this method narrow.
// Delete 物理删除媒体库。
func (r *LibraryRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&model.Library{}, "id = ?", id).Error
return r.db.WithContext(ctx).Unscoped().Delete(&model.Library{}, "id = ?", id).Error
}
func (r *LibraryRepository) ListRoots(ctx context.Context, libraryID string) ([]model.LibraryRoot, error) {
@@ -149,7 +148,7 @@ func (r *LibraryRepository) DeleteRoot(ctx context.Context, libraryID, rootID st
if !r.hasLibraryRootsTable() {
return nil
}
return r.db.WithContext(ctx).Where("library_id = ?", libraryID).Delete(&model.LibraryRoot{}, "id = ?", rootID).Error
return r.db.WithContext(ctx).Unscoped().Where("library_id = ?", libraryID).Delete(&model.LibraryRoot{}, "id = ?", rootID).Error
}
func (r *LibraryRepository) hasLibraryRootsTable() bool {
+3 -3
View File
@@ -116,12 +116,12 @@ func (r *MediaRepository) ListByLibrariesFiltered(ctx context.Context, libraryID
// DeleteByLibrary purges all media tied to a library.
func (r *MediaRepository) DeleteByLibrary(ctx context.Context, libraryID string) error {
// FTS 行由 media 表上的触发器同步清理(软删/硬删都覆盖)。
return r.db.WithContext(ctx).Where("library_id = ?", libraryID).Delete(&model.Media{}).Error
// FTS 行由 media 表上的触发器同步清理(物理删除触发 FTS 清理)。
return r.db.WithContext(ctx).Unscoped().Where("library_id = ?", libraryID).Delete(&model.Media{}).Error
}
func (r *MediaRepository) DeleteByLibraryRoot(ctx context.Context, libraryID, rootID string) error {
return r.db.WithContext(ctx).
return r.db.WithContext(ctx).Unscoped().
Where("library_id = ? AND library_root_id = ?", libraryID, rootID).
Delete(&model.Media{}).Error
}
+2 -2
View File
@@ -51,9 +51,9 @@ func (r *PermissionRepository) Upsert(ctx context.Context, p *model.UserPermissi
})
}
// Delete removes a permission record.
// Delete 物理删除权限记录。
func (r *PermissionRepository) Delete(ctx context.Context, userID string) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&model.UserPermission{}).Error
return r.db.WithContext(ctx).Unscoped().Where("user_id = ?", userID).Delete(&model.UserPermission{}).Error
})
}
+2 -2
View File
@@ -59,9 +59,9 @@ func (r *PlayProfileRepository) Update(ctx context.Context, id string, patch map
Where("id = ?", id).Updates(patch).Error
}
// Delete soft-deletes a profile.
// Delete 物理删除播放档案。
func (r *PlayProfileRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&model.PlayProfile{}, "id = ?", id).Error
return r.db.WithContext(ctx).Unscoped().Delete(&model.PlayProfile{}, "id = ?", id).Error
}
// ClearDefaultsFor resets is_default for all of a user's profiles.
@@ -72,10 +72,10 @@ func (r *RefreshTokenRepository) RevokeOldestActiveByUserID(ctx context.Context,
})
}
// DeleteExpired removes all expired refresh tokens.
// DeleteExpired 物理清理所有过期的 refresh tokens。
func (r *RefreshTokenRepository) DeleteExpired(ctx context.Context) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Where("expires_at < ?", time.Now()).Delete(&model.RefreshToken{}).Error
return r.db.WithContext(ctx).Unscoped().Where("expires_at < ?", time.Now()).Delete(&model.RefreshToken{}).Error
})
}
+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},
}
}
+2 -2
View File
@@ -29,9 +29,9 @@ func (r *SettingRepository) Set(ctx context.Context, key, value string) error {
return r.db.WithContext(ctx).Save(&s).Error
}
// Delete removes a setting key.
// Delete 物理删除设置键。
func (r *SettingRepository) Delete(ctx context.Context, key string) error {
return r.db.WithContext(ctx).Where("key = ?", key).Delete(&model.Setting{}).Error
return r.db.WithContext(ctx).Unscoped().Where("key = ?", key).Delete(&model.Setting{}).Error
}
// All returns every key/value pair (used by the admin UI).
+2 -2
View File
@@ -66,9 +66,9 @@ func (r *StorageConfigRepository) Upsert(ctx context.Context, c *model.StorageCo
}).Error
}
// Delete removes a storage config by ID.
// Delete 物理删除存储配置。
func (r *StorageConfigRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.StorageConfig{}).Error
return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.StorageConfig{}).Error
}
// FindByID returns a storage config by ID.
+346 -141
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).Unscoped().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).Unscoped().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,97 @@ 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).Unscoped().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).Unscoped().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).Unscoped().Where("status IN ?", []string{model.StrmTaskDone, model.StrmTaskFailed, model.StrmTaskCanceled}).
Delete(&model.StrmDownloadTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
// ClearCanceled 清空全部已取消下载任务。
func (r *StrmDownloadTaskRepository) ClearCanceled(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.StrmTaskCanceled).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 批量取消所有排队中的任务。
// 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 IN ?", []string{model.StrmTaskPending, model.StrmTaskRunning}).
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 +374,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).Unscoped().Where("status IN ? AND finished_at < ?",
[]string{model.StrmTaskDone, model.StrmTaskFailed, model.StrmTaskCanceled}, before).
Delete(&model.StrmDownloadTask{}).Error
})
}
// ─── StrmUploadTask ────────────────────────────────────────────────────────────
@@ -311,7 +404,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 +487,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 +518,53 @@ 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).Unscoped().Where("id = ?", id).Delete(&model.StrmUploadTask{}).Error
})
}
// ClearCanceled 清空全部已取消上传任务。
func (r *StrmUploadTaskRepository) ClearCanceled(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.StrmTaskCanceled).Delete(&model.StrmUploadTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
// CancelPending 批量取消所有排队中和进行中的任务。
func (r *StrmUploadTaskRepository) CancelPending(ctx context.Context) (int64, error) {
now := time.Now()
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
Where("status IN ?", []string{model.StrmTaskPending, model.StrmTaskRunning}).
Updates(map[string]any{
"status": model.StrmTaskCanceled,
"error": "已批量取消",
"finished_at": now,
"updated_at": now,
})
count = res.RowsAffected
return res.Error
})
return count, err
}
// CountActive 统计某同步目录下目标仍在排队/进行的任务数(用于去重)。
@@ -431,8 +577,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).Unscoped().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).Unscoped().Where("sync_path_id = ?", syncPathID).Delete(&model.StrmDirCache{}).Error
})
}
+11 -20
View File
@@ -133,26 +133,17 @@ func (r *UserRepository) TouchLogin(ctx context.Context, id string) error {
})
}
// Delete removes a user (soft-delete via gorm.DeletedAt), releases the unique
// username, and drops Telegram bindings so future re-created users bind cleanly.
// Delete 物理删除用户并级联清理其关联记录。
func (r *UserRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var user model.User
if err := tx.Where("id = ?", id).First(&user).Error; err != nil {
return err
}
released := user.Username + "__deleted__" + time.Now().Format("20060102150405.000000000")
if len(released) > 64 {
sum := sha256.Sum256([]byte(user.ID + user.Username))
base := user.Username
if len(base) > 43 {
base = base[:43]
}
released = base + "__deleted__" + hex.EncodeToString(sum[:])[:10]
}
if err := tx.Model(&model.User{}).Where("id = ?", id).Update("username", released).Error; err != nil {
return err
}
return tx.Delete(&model.User{}, "id = ?", id).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
_ = tx.Unscoped().Where("user_id = ?", id).Delete(&model.RefreshToken{})
_ = tx.Unscoped().Where("user_id = ?", id).Delete(&model.UserPermission{})
_ = tx.Unscoped().Where("user_id = ?", id).Delete(&model.PlayProfile{})
_ = tx.Unscoped().Where("user_id = ?", id).Delete(&model.PlaybackHistory{})
_ = tx.Unscoped().Where("user_id = ?", id).Delete(&model.Favorite{})
_ = tx.Unscoped().Where("user_id = ?", id).Delete(&model.UserDevice{})
return tx.Unscoped().Delete(&model.User{}, "id = ?", id).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"`
}
+16 -9
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
}
+1
View File
@@ -90,6 +90,7 @@ 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:"-"` // 原始响应体(外层附加字段用)
}
+58 -3
View File
@@ -371,7 +371,62 @@ func TestThrottleCodeHandling(t *testing.T) {
if err == nil {
t.Fatal("expected throttle error")
}
if !tm.IsThrottled() {
t.Fatal("code 770004 should trigger throttle status")
if !tm.IsThrottled() {
t.Fatal("code 770004 should trigger throttle status")
}
}
}
func TestRemoteFileDetailRelativePath(t *testing.T) {
rootCID := "3238787832374488117" // 影视库
// 场景 1:115 目录 paths 中只有祖先目录链,不包含自身
d1 := &RemoteFileDetail{
FileId: "3251154147730910635",
FileName: "出包王女",
Paths: []struct {
FileId string `json:"file_id"`
Name string `json:"file_name"`
}{
{FileId: "0", Name: "根目录"},
{FileId: "3238787832374488117", Name: "影视库"},
{FileId: "3238787913223892116", Name: "动漫"},
},
}
if got := d1.RelativePath(rootCID); got != "动漫/出包王女" {
t.Errorf("d1.RelativePath = %q, want %q", got, "动漫/出包王女")
}
// 场景 2:祖先中间目录,自身在 paths 末尾
d2 := &RemoteFileDetail{
FileId: "3238787913223892116",
FileName: "动漫",
Paths: []struct {
FileId string `json:"file_id"`
Name string `json:"file_name"`
}{
{FileId: "0", Name: "根目录"},
{FileId: "3238787832374488117", Name: "影视库"},
{FileId: "3238787913223892116", Name: "动漫"},
},
}
if got := d2.RelativePath(rootCID); got != "动漫" {
t.Errorf("d2.RelativePath = %q, want %q", got, "动漫")
}
// 场景 3:根同步目录自身
d3 := &RemoteFileDetail{
FileId: rootCID,
FileName: "影视库",
Paths: []struct {
FileId string `json:"file_id"`
Name string `json:"file_name"`
}{
{FileId: "0", Name: "根目录"},
{FileId: rootCID, Name: "影视库"},
},
}
if got := d3.RelativePath(rootCID); got != "" {
t.Errorf("d3.RelativePath = %q, want %q", got, "")
}
}
+73
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,49 @@ type RemoteFileDetail struct {
} `json:"paths"`
}
// RelativePath 计算该目录相对于根同步目录(rootCID)的相对路径。
func (d *RemoteFileDetail) RelativePath(rootCID string) string {
if d == nil {
return ""
}
if rootCID == "" {
rootCID = "0"
}
if d.FileId == rootCID {
return ""
}
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
}
hasSelf := false
for i := start; i < len(d.Paths); i++ {
if d.Paths[i].FileId == d.FileId {
hasSelf = true
}
name := strings.TrimSpace(d.Paths[i].Name)
if name != "" {
segments = append(segments, name)
}
}
// 若 115 返回的 paths 祖先链未包含当前目录自身,则将其自身目录名 FileName 补在末尾
if !hasSelf && strings.TrimSpace(d.FileName) != "" && d.FileId != rootCID {
segments = append(segments, strings.TrimSpace(d.FileName))
}
return strings.Join(segments, "/")
}
// ─── 下载直链 ──────────────────────────────────────────────────────────────────
type downloadURLData struct {
+69 -14
View File
@@ -92,6 +92,10 @@ func TestDanmakuFetchHashMatchLayer(t *testing.T) {
require.Equal(t, "xml", res.SourceType)
require.Contains(t, res.Raw, "弹幕Hash命中")
require.Empty(t, res.Candidates)
require.Equal(t, "测试动画", res.AnimeTitle)
require.Equal(t, "第1话", res.EpisodeTitle)
require.Equal(t, int64(25484), res.EpisodeID)
require.Equal(t, "hash", res.MatchMode)
// match 请求体:文件名去扩展名并 URL 转义(官方接口要求,实测验证)、
// hash、大小、matchMode 齐全。
@@ -274,18 +278,69 @@ func TestDanmakuSameBase(t *testing.T) {
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)
// 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)
}
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, "手动搜索弹幕")
}
+87 -65
View File
@@ -67,11 +67,17 @@ type DanmakuRenderConfig struct {
//
// Candidates is non-nil when multiple anime matched the search and the player
// must ask the user which one to use (disambiguation); Raw is empty then.
// AnimeTitle, EpisodeTitle, EpisodeID and MatchMode provide matched danmaku
// metadata so the player UI can display which episode was loaded.
type DanmakuFetchResult struct {
DanmakuRenderConfig
SourceType string `json:"source_type"`
Raw string `json:"raw,omitempty"`
Candidates []DanmakuAnime `json:"candidates,omitempty"`
SourceType string `json:"source_type"`
Raw string `json:"raw,omitempty"`
Candidates []DanmakuAnime `json:"candidates,omitempty"`
AnimeTitle string `json:"anime_title,omitempty"`
EpisodeTitle string `json:"episode_title,omitempty"`
EpisodeID int64 `json:"episode_id,omitempty"`
MatchMode string `json:"match_mode,omitempty"`
}
// DanmakuAnime is one search hit (an anime) with its episode list, mirroring
@@ -184,74 +190,90 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
configured := strings.TrimRight(strings.TrimSpace(res.Source), "/")
official := danmakuOfficialBase
// 手动指定弹幕库:跳过识别,直接拉取该库(自定义源失败回退官方)。
if target := strings.TrimSpace(episodeID); target != "" {
raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target)
if err != nil {
s.log.Warn("danmaku comment fetch failed", zap.String("media_id", mediaID), zap.String("episode_id", target), zap.Error(err))
return res, err
}
res.Raw, res.SourceType = raw, st
return res, nil
}
term, media, err := s.searchTerms(ctx, mediaID)
if err != nil {
return res, err
}
manualKeyword := strings.TrimSpace(keyword) != ""
if kw := strings.TrimSpace(keyword); kw != "" {
term.name = kw
}
if strings.TrimSpace(term.name) == "" {
return res, nil
}
target := ""
// 1) hash 识别:始终走官方 /api/v2/match。
if media != nil && media.Path != "" {
if hash, ok := s.mediaHash(ctx, media); ok {
fileSize := media.SizeBytes
if strings.EqualFold(filepath.Ext(media.Path), ".strm") {
fileSize = 0 // strm 行的 SizeBytes 是文本大小,不是视频大小
}
matches, err := s.matchOfficial(ctx, danmakuMatchFileName(media.Path), hash, fileSize, media.DurationSec)
// 手动指定弹幕库:跳过识别,直接拉取该库(自定义源失败回退官方)。
if target := strings.TrimSpace(episodeID); target != "" {
raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target)
if err != nil {
s.log.Warn("danmaku hash match failed", zap.String("media_id", mediaID), zap.Error(err))
} else if len(matches) > 0 {
target = fmt.Sprintf("%d", matches[0].EpisodeID)
s.log.Warn("danmaku comment fetch failed", zap.String("media_id", mediaID), zap.String("episode_id", target), zap.Error(err))
return res, err
}
}
}
// 2) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 3 层)。
if target == "" && !manualKeyword && media != nil && media.Path != "" {
if fileName := danmakuMatchFileName(media.Path); fileName != "" && fileName != term.name {
if candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, fileName, term.episode); err == nil &&
len(candidates) == 1 && len(candidates[0].Episodes) > 0 {
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
res.Raw, res.SourceType = raw, st
if id, parseErr := strconv.ParseInt(target, 10, 64); parseErr == nil {
res.EpisodeID = id
}
}
}
// 3) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数,
// 多结果返回候选列表交给播放器(歧义处理)。
if target == "" {
candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, term.name, term.episode)
if err != nil {
s.log.Warn("danmaku search failed", zap.String("media_id", mediaID), zap.String("name", term.name), zap.String("episode", term.episode), zap.Error(err))
return res, err
}
if len(candidates) != 1 {
res.Candidates = candidates
res.MatchMode = "manual"
return res, nil
}
if len(candidates[0].Episodes) == 0 {
return res, errors.New("no danmaku library found for this video")
term, media, err := s.searchTerms(ctx, mediaID)
if err != nil {
return res, err
}
manualKeyword := strings.TrimSpace(keyword) != ""
if kw := strings.TrimSpace(keyword); kw != "" {
term.name = kw
}
if strings.TrimSpace(term.name) == "" {
return res, nil
}
target := ""
// 1) hash 识别:始终走官方 /api/v2/match(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"
}
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
}
raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target)
if err != nil {
+6
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) {
@@ -262,6 +266,8 @@ func TestDanmakuFetchWithExplicitEpisodeID(t *testing.T) {
require.True(t, res.Enabled)
require.Contains(t, res.Raw, "显式指定弹幕")
require.Empty(t, res.Candidates)
require.Equal(t, int64(99999), res.EpisodeID)
require.Equal(t, "manual", res.MatchMode)
}
func TestDetectDanmakuSourceType(t *testing.T) {
cases := []struct {
+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 {
+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")
}
}
+4 -4
View File
@@ -43,10 +43,10 @@ func (s *MediaService) DeleteLibrary(ctx context.Context, id string) error {
if err := tx.Unscoped().Where("library_id = ?", id).Delete(&model.Media{}).Error; err != nil {
return err
}
if err := hardDeleteLibraryRoots(ctx, tx, id); err != nil {
return err
}
return tx.Delete(&model.Library{}, "id = ?", id).Error
if err := hardDeleteLibraryRoots(ctx, tx, id); err != nil {
return err
}
return tx.Unscoped().Delete(&model.Library{}, "id = ?", id).Error
})
if err == nil {
s.invalidateMediaCache(ctx)
+2 -17
View File
@@ -10,25 +10,10 @@ import (
const maxRecycleBinRecords = 200
// SoftDelete moves a media row to the recycle bin (gorm soft delete).
// The on-disk file is kept; admins can purge it later.
// SoftDelete 物理删除媒体记录(统一硬删除以降低 SQLite 存储与索引压力)。
func (s *MediaService) SoftDelete(ctx context.Context, id string) error {
media, err := s.repo.Media.FindByID(ctx, id)
if err != nil {
return err
}
if media != nil && isCloudMediaPath(media.Path) {
err := s.repo.DB.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.Media{}).Error
if err == nil {
s.invalidateMediaCache(ctx)
}
return err
}
err = s.repo.DB.WithContext(ctx).Where("id = ?", id).Delete(&model.Media{}).Error
err := s.repo.DB.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.Media{}).Error
if err == nil {
if pruneErr := pruneRecycleBinRows(ctx, s.repo.DB, maxRecycleBinRecords); pruneErr != nil {
return pruneErr
}
s.invalidateMediaCache(ctx)
}
return err
@@ -229,9 +229,9 @@ func (o *OrganizerService) replaceVersions(ctx context.Context, src string, exis
o.log.Warn("organize replace remove existing failed",
zap.String("path", e), zap.Error(err))
}
if o.repo != nil && o.repo.DB != nil {
_ = o.repo.DB.WithContext(ctx).Where("path = ?", e).Delete(&model.Media{}).Error
}
if o.repo != nil && o.repo.DB != nil {
_ = o.repo.DB.WithContext(ctx).Unscoped().Where("path = ?", e).Delete(&model.Media{}).Error
}
}
// Move staged file + sidecars into the final path.
if err := os.Rename(stage, dst); err != nil {
@@ -97,7 +97,7 @@ func (o *OrganizerService) deleteMediaRowForPath(ctx context.Context, path strin
if o == nil || o.repo == nil || o.repo.DB == nil {
return
}
_ = o.repo.DB.WithContext(ctx).Where("path = ?", path).Delete(&model.Media{}).Error
_ = o.repo.DB.WithContext(ctx).Unscoped().Where("path = ?", path).Delete(&model.Media{}).Error
}
func (o *OrganizerService) mediaPathExists(ctx context.Context, path string) bool {
+5 -5
View File
@@ -196,18 +196,18 @@ func (p *PlaybackService) AddToPlaylist(ctx context.Context, playlistID, mediaID
return p.repo.DB.Create(item).Error
}
// RemoveFromPlaylist removes a media item from a playlist (idempotent).
// RemoveFromPlaylist 物理删除播放列表项(幂等)。
func (p *PlaybackService) RemoveFromPlaylist(ctx context.Context, playlistID, mediaID string) error {
return p.repo.DB.
return p.repo.DB.WithContext(ctx).Unscoped().
Where("playlist_id = ? AND media_id = ?", playlistID, mediaID).
Delete(&model.PlaylistItem{}).Error
}
// DeletePlaylist removes a playlist and all of its items.
// DeletePlaylist 物理删除播放列表及其全部条目。
func (p *PlaybackService) DeletePlaylist(ctx context.Context, playlistID string) error {
if err := p.repo.DB.Where("playlist_id = ?", playlistID).
if err := p.repo.DB.WithContext(ctx).Unscoped().Where("playlist_id = ?", playlistID).
Delete(&model.PlaylistItem{}).Error; err != nil {
return err
}
return p.repo.DB.Where("id = ?", playlistID).Delete(&model.Playlist{}).Error
return p.repo.DB.WithContext(ctx).Unscoped().Where("id = ?", playlistID).Delete(&model.Playlist{}).Error
}
+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 -5
View File
@@ -11,13 +11,12 @@ import (
"github.com/ShukeBta/MMTL/internal/model"
)
// RemovePath deletes the media row for a path that has disappeared from disk
// (incremental delete used by the watcher on Remove/Rename events).
// RemovePath 物理删除磁盘上已不存在的媒体记录。
func (s *ScannerService) RemovePath(ctx context.Context, path string) (int64, error) {
if _, err := os.Stat(path); err == nil {
return 0, nil // still exists; nothing to remove
}
res := s.repo.DB.WithContext(ctx).
res := s.repo.DB.WithContext(ctx).Unscoped().
Where("path = ?", path).
Delete(&model.Media{})
if res.Error == nil && res.RowsAffected > 0 {
@@ -55,7 +54,7 @@ func (s *ScannerService) pruneMissingMedia(ctx context.Context, libraryID string
}
stale = append(stale, row.ID)
}
return s.deleteMediaByIDs(ctx, stale, false)
return s.deleteMediaByIDs(ctx, stale, true)
}
func (s *ScannerService) pruneMissingMediaForRoot(ctx context.Context, libraryID, rootID, rootPath string, seen map[string]struct{}) (int64, error) {
@@ -92,7 +91,7 @@ func (s *ScannerService) pruneMissingMediaForRoot(ctx context.Context, libraryID
}
stale = append(stale, row.ID)
}
return s.deleteMediaByIDs(ctx, stale, false)
return s.deleteMediaByIDs(ctx, stale, true)
}
func pathBelongsToRoot(pathValue, rootPath string) bool {
+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 构建服务容器。
+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 路径。
+15
View File
@@ -501,6 +501,16 @@ func (s *StrmService) ClearFinishedDownloadTasks(ctx context.Context) (int64, er
return s.repo.StrmDownload.ClearFinished(ctx)
}
// ClearCanceledDownloadTasks 清空全部已取消的下载记录,返回删除数量。
func (s *StrmService) ClearCanceledDownloadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmDownload.ClearCanceled(ctx)
}
// ClearCanceledUploadTasks 清空全部已取消的上传记录,返回删除数量。
func (s *StrmService) ClearCanceledUploadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmUpload.ClearCanceled(ctx)
}
// RetryAllFailedDownloadTasks 批量重试所有失败下载任务,返回重新入队数量。
func (s *StrmService) RetryAllFailedDownloadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmDownload.RetryAllFailed(ctx)
@@ -511,6 +521,11 @@ func (s *StrmService) CancelPendingDownloadTasks(ctx context.Context) (int64, er
return s.repo.StrmDownload.CancelPending(ctx)
}
// CancelPendingUploadTasks 批量取消所有排队上传任务,返回取消数量。
func (s *StrmService) CancelPendingUploadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmUpload.CancelPending(ctx)
}
func sleepContext(ctx context.Context, d time.Duration) {
select {
case <-ctx.Done():
+25
View File
@@ -113,6 +113,7 @@ func NewStrmService(cfg *config.Config, log *zap.Logger, repos *repository.Conta
// Start 启动下载/上传队列 worker、定时同步巡检、115 token 刷新与队列清理。
func (s *StrmService) Start(ctx context.Context) {
s.sync115RelayKey(ctx)
s.recoverInterruptedSyncs(ctx)
downloadThreads := s.strmIntSetting(ctx, StrmSettingDownloadThreads, 3)
if downloadThreads < 1 {
downloadThreads = 1
@@ -141,6 +142,21 @@ func (s *StrmService) Start(ctx context.Context) {
zap.Int("upload_threads", uploadThreads))
}
// recoverInterruptedSyncs 在服务启动时自愈重置因服务重启遗留的 running 状态。
func (s *StrmService) recoverInterruptedSyncs(ctx context.Context) {
paths, err := s.repo.StrmSyncPath.List(ctx)
if err == nil {
for i := range paths {
p := &paths[i]
if p.LastSyncStatus == model.StrmSyncRecordRunning {
p.LastSyncStatus = model.StrmSyncRecordCanceled
p.LastSyncMessage = "服务重启,已重置同步状态"
_ = s.repo.StrmSyncPath.Update(ctx, p)
}
}
}
}
func (s *StrmService) Stop() {
s.stopOnce.Do(func() { close(s.stopCh) })
}
@@ -404,6 +420,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 +449,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 表达式")
}
+511 -104
View File
@@ -20,6 +20,7 @@ import (
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/service/cloud"
"github.com/ShukeBta/MMTL/internal/service/cloud115"
)
// strmSyncState 是一次同步执行的上下文。
@@ -31,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, "同步目录不存在")
@@ -64,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,
}
@@ -84,15 +104,27 @@ func (s *StrmService) StartSync(ctx context.Context, pathID string) error {
return nil
}
// CancelSync 取消正在进行的同步。
// CancelSync 取消正在进行的同步(若为僵尸运行状态则直接自愈重置)。
func (s *StrmService) CancelSync(ctx context.Context, pathID string) error {
s.mu.Lock()
defer s.mu.Unlock()
cancel, exists := s.running[pathID]
if !exists {
return errors.New("该目录当前没有进行中的同步")
if exists {
delete(s.running, pathID)
}
s.mu.Unlock()
if exists && cancel != nil {
cancel()
}
// 无论内存中是否活跃,确保同步目录状态正确重置为已取消
if p, err := s.repo.StrmSyncPath.FindByID(ctx, pathID); err == nil && p != nil {
if p.LastSyncStatus == model.StrmSyncRecordRunning {
p.LastSyncStatus = model.StrmSyncRecordCanceled
p.LastSyncMessage = "已取消"
_ = s.repo.StrmSyncPath.Update(ctx, p)
}
}
cancel()
return nil
}
@@ -145,6 +177,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{},
@@ -191,15 +224,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))
}
@@ -207,20 +244,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
@@ -239,6 +299,7 @@ const strmScanWorkers = 8
// 多个 worker 并行执行 List(受全局 115 令牌桶限流约束),子目录动态
// 入队;任一目录失败则取消其余 worker 并返回错误(与旧串行版语义一致)。
func (st *strmSyncState) walkRemote() error {
defer st.flushPendingDownloads()
root := strings.TrimSpace(st.p.RemotePath)
if root == "" {
root = "/"
@@ -374,6 +435,239 @@ 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 {
pathCounts := make(map[string]int, len(cached))
for _, item := range cached {
pathCounts[item.Path]++
}
for _, item := range cached {
// 若同一个 path 对应了多个不同 dir_id,说明包含历史层级塌陷的脏数据,不预加载,让后续步骤重新向 115 获取精确路径
if pathCounts[item.Path] > 1 {
continue
}
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)
}
st.updateSyncMessage(fmt.Sprintf("正在拉取远端文件列表 (共 %d 个文件)...", totalCount))
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 := 8
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 = 8
doneDirs atomic.Int64
totalDirs = len(pidList)
)
if len(pidList) < dirWorkers {
dirWorkers = len(pidList)
}
st.updateSyncMessage(fmt.Sprintf("正在解析目录树 (0/%d)...", totalDirs))
for i := 0; i < dirWorkers; i++ {
pwg.Add(1)
go func() {
defer pwg.Done()
for pid := range pidCh {
if ctx.Err() != nil {
return
}
if _, loaded := st.dirCache.Load(pid); loaded {
if n := doneDirs.Add(1); n%20 == 0 || n == int64(totalDirs) {
st.updateSyncMessage(fmt.Sprintf("正在解析目录树 (%d/%d)...", n, totalDirs))
}
continue
}
detail, err := open115.GetFsDetailByCid(ctx, pid)
if err != nil {
st.s.log.Warn("115: 获取目录详情失败", zap.String("pid", pid), zap.Error(err))
} else if detail != nil {
// 解析相对路径
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,
FileName: ancestor.Name,
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)
}
}
}
if n := doneDirs.Add(1); n%10 == 0 || n == int64(totalDirs) {
st.updateSyncMessage(fmt.Sprintf("正在解析目录树 (%d/%d)...", n, totalDirs))
}
}
}()
}
pwg.Wait()
}
st.updateSyncMessage(fmt.Sprintf("正在生成 STRM 与同步文件 (共 %d 个)...", len(allFiles)))
// 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)]
@@ -391,6 +685,18 @@ 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 {
// 并发 worker 下 rec.Message 无锁写会有数据竞争,这里仅记录日志;
@@ -403,6 +709,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()
@@ -423,6 +734,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()
@@ -482,6 +797,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)
@@ -494,10 +839,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,
@@ -516,13 +873,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()
}
@@ -576,93 +936,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 {
@@ -748,12 +1139,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()
@@ -769,6 +1164,18 @@ func (st *strmSyncState) flushProgress() {
}
}
// updateSyncMessage 实时更新同步阶段提示信息,让前端界面清晰了解当前进度。
func (st *strmSyncState) updateSyncMessage(msg string) {
st.mu.Lock()
st.rec.Message = msg
st.p.LastSyncMessage = msg
rec := *st.rec
p := *st.p
st.mu.Unlock()
_ = st.s.repo.StrmSyncRecord.Update(st.ctx, &rec)
_ = st.s.repo.StrmSyncPath.Update(st.ctx, &p)
}
// ─── 定时同步巡检 ──────────────────────────────────────────────────────────────
func (s *StrmService) cronLoop(ctx context.Context) {
+120 -7
View File
@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -34,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 {
@@ -159,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 {
@@ -442,7 +494,68 @@ func TestWalkRemoteConcurrent(t *testing.T) {
if walkErr != nil {
t.Fatal(walkErr)
}
if strmCount != 5 {
t.Errorf("生成的 .strm 数量 = %d,期望 5", strmCount)
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)
}
}
+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
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"context"
"fmt"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/ShukeBta/MMTL/internal/service/cloud115"
"go.uber.org/zap"
)
func main() {
crypto := service.NewCryptoService("test-secret", zap.NewNop())
// Let's test with a mock RemoteFileDetail
d := &cloud115.RemoteFileDetail{
FileId: "3251154147730910635",
FileName: "出包王女",
Paths: []struct {
FileId string
Name string
}{
{FileId: "0", Name: "根目录"},
{FileId: "3238787832374488117", Name: "影视库"},
{FileId: "3238787913223892116", Name: "动漫"},
},
}
fmt.Println("RelativePath when rootCID is 3238787832374488117:", d.RelativePath("3238787832374488117"))
}
+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 = {
+11 -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),
@@ -142,6 +143,9 @@ export const strmAPI = {
clearFinishedDownloads: () =>
api.post<{ deleted: number }>('/admin/strm/downloads/clear-finished').then((r) => r.data),
clearCanceledDownloads: () =>
api.post<{ deleted: number }>('/admin/strm/downloads/clear-canceled').then((r) => r.data),
retryFailedDownloads: () =>
api.post<{ retried: number }>('/admin/strm/downloads/retry-failed').then((r) => r.data),
@@ -158,6 +162,12 @@ export const strmAPI = {
cancelUpload: (id: string) =>
api.post(`/admin/strm/uploads/${id}/cancel`).then((r) => r.data),
cancelPendingUploads: () =>
api.post<{ canceled: number }>('/admin/strm/uploads/cancel-pending').then((r) => r.data),
clearCanceledUploads: () =>
api.post<{ deleted: number }>('/admin/strm/uploads/clear-canceled').then((r) => r.data),
retryUpload: (id: string) =>
api.post(`/admin/strm/uploads/${id}/retry`).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">
+127 -33
View File
@@ -16,11 +16,7 @@ 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 CC button opens a track picker (关闭 / 各轨道).
// 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
@@ -50,7 +46,10 @@ export function PlayerControls({
onToggleDanmaku,
}: PlayerControlsProps) {
const video = () => videoRef.current
const container = () => videoRef.current?.parentElement ?? null
const container = () =>
videoRef.current?.closest<HTMLElement>('[data-player-stage]') ??
videoRef.current?.parentElement ??
null
const [playing, setPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
@@ -60,9 +59,32 @@ 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)
useEffect(() => {
controlsHoveredRef.current = controlsHovered
}, [controlsHovered])
useEffect(() => {
isScrubbingRef.current = isScrubbing
}, [isScrubbing])
useEffect(() => {
subtitleMenuOpenRef.current = subtitleMenuOpen
}, [subtitleMenuOpen])
useEffect(() => {
danmakuOpenRef.current = danmakuOpen
}, [danmakuOpen])
// 点击控制栏外部时关闭字幕菜单
useEffect(() => {
@@ -76,42 +98,74 @@ export function PlayerControls({
return () => document.removeEventListener('mousedown', onDocClick)
}, [subtitleMenuOpen])
// 播放时 3 秒无操作自动隐藏控制栏;暂停时保持显示。监听挂在视频容器上,
// 控制栏隐藏(pointer-events-none)后移动鼠标仍能重新唤起。
// 播放时 3 秒无操作自动隐藏控制栏;暂停/悬停/拖动进度条/打开菜单时保持显示。
// 监听挂在整个播放器舞台容器(data-player-stage)上,避免光标移到控制栏时因离开视频画面而误触发 mouseleave。
useEffect(() => {
const el = video()
if (!el) return
const parent = el.parentElement
const onMove = () => {
setUiVisible(true)
const stage = container()
if (!el || !stage) return
const resetTimer = () => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
if (!el.paused) {
hideTimerRef.current = setTimeout(() => setUiVisible(false), 3000)
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 onLeave = () => {
if (el.paused) return
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)
@@ -128,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)
@@ -145,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
@@ -152,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) => {
@@ -199,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"
@@ -220,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 && (
+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>
+16 -11
View File
@@ -328,19 +328,24 @@ body {
}
/* ── 播放器字幕(WebVTT <track>)──
去掉浏览器默认黑底,用文字阴影保证亮画面下的可读性;
font 简写携带 line-height 收紧两行字幕的行距(默认行距偏大)。 */
video::cue {
background-color: transparent;
color: #fff;
font:
500 1.15em/1.35 "PingFang SC",
彻底去除浏览器 User Agent 默认的半透明黑底背景框,使用文字阴影保证亮暗画面下的可读性。 */
::cue,
video::cue,
::cue(*),
video::cue(*) {
background: transparent !important;
background-color: transparent !important;
color: #ffffff !important;
font-family:
"PingFang SC",
"Microsoft YaHei",
"Noto Sans CJK SC",
"Source Han Sans SC",
sans-serif;
sans-serif !important;
font-weight: 500 !important;
line-height: 1.35 !important;
text-shadow:
0 1px 3px rgba(0, 0, 0, 0.9),
0 0 8px rgba(0, 0, 0, 0.55),
0 0 16px rgba(0, 0, 0, 0.35);
0 1px 3px rgba(0, 0, 0, 0.95),
0 0 8px rgba(0, 0, 0, 0.8),
0 0 16px rgba(0, 0, 0, 0.6) !important;
}
+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>
+23 -4
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'
@@ -72,6 +72,8 @@ export function PlayerPage() {
const [danmakuEpisodeId, setDanmakuEpisodeId] = useState<number | string | null>(null)
// 自动匹配歧义(多番剧命中)时的候选列表。
const [danmakuCandidates, setDanmakuCandidates] = useState<DanmakuAnime[]>([])
// 已加载弹幕的元数据信息(番剧名、单集名、条数、匹配模式等)。
const [danmakuInfo, setDanmakuInfo] = useState<DanmakuLoadedInfo | null>(null)
// 用户当前选定的弹幕来源描述(面板中展示)。
const [danmakuSelectedSource, setDanmakuSelectedSource] = useState('')
const [danmakuOpacity, setDanmakuOpacity] = useState(1)
@@ -129,17 +131,20 @@ export function PlayerPage() {
setDanmakuSearching(true)
setDanmakuCandidates([])
setDanmakuEpisodeId(null)
setDanmakuInfo(null)
setDanmakuSearch(kw || null)
}, [])
const danmakuLoaded = useCallback(() => {
const danmakuLoaded = useCallback((info: DanmakuLoadedInfo | null) => {
setDanmakuSearching(false)
setDanmakuInfo(info)
}, [])
// 多番剧命中(disambiguation):展示候选让用户选择。
const danmakuGotCandidates = useCallback((candidates: DanmakuAnime[]) => {
setDanmakuCandidates(candidates)
setDanmakuSearching(false)
setDanmakuInfo(null)
// 候选是静默返回的(此时没有任何弹幕);自动打开面板提示用户选择来源。
if (candidates.length > 0) setDanmakuOpen(true)
}, [])
@@ -150,7 +155,7 @@ export function PlayerPage() {
setDanmakuCandidates([])
setDanmakuSearching(true)
// 展示当前所选来源(面板标题处可见)。
setDanmakuSelectedSource(`${animeTitle}・${episodeTitle}`)
setDanmakuSelectedSource(episodeTitle ? `${animeTitle}・${episodeTitle}` : animeTitle)
}, [])
// 回到自动匹配(清除用户手动选择)。
@@ -159,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
@@ -304,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}
@@ -346,6 +364,7 @@ export function PlayerPage() {
onFontSizeChange={setDanmakuFontSize}
candidates={danmakuCandidates}
selectedSource={danmakuSelectedSource}
danmakuInfo={danmakuInfo}
onSelectEpisode={danmakuSelectEpisode}
onResetAuto={danmakuResetAuto}
/>
+187 -62
View File
@@ -1,8 +1,8 @@
import { useEffect } from 'react'
import { useEffect, useRef, useState } from 'react'
import type { ReactNode, RefObject } from 'react'
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
import { type DanmakuAnime } from '../api/danmaku'
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
import type { Media } from '../types'
import { DanmakuStage } from '../components/DanmakuStage'
import { PlayerControls } from '../components/PlayerControls'
@@ -24,7 +24,7 @@ type PlayerVideoStageProps = {
danmakuEpisodeId: number | string | null
danmakuOpen: boolean
onToggleDanmaku: () => void
onDanmakuLoaded: () => void
onDanmakuLoaded: (info: DanmakuLoadedInfo | null) => void
onDanmakuCandidates: (candidates: DanmakuAnime[]) => void
/** Danmaku settings panel; rendered inside the stage so it stays visible in fullscreen. */
danmakuPanel: ReactNode
@@ -50,6 +50,47 @@ export function PlayerVideoStage({
onDanmakuCandidates,
danmakuPanel,
}: PlayerVideoStageProps) {
const stageRef = useRef<HTMLDivElement>(null)
const [videoRatio, setVideoRatio] = useState<number | null>(null)
const [stageRect, setStageRect] = useState<{ width: number; height: number } | null>(null)
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
const [activeCueText, setActiveCueText] = useState<string>('')
// 监听舞台容器的真实尺寸(响应窗口大小调整和全屏切换)
useEffect(() => {
const stage = stageRef.current
if (!stage) return
const ro = new ResizeObserver((entries) => {
const entry = entries[0]
if (entry) {
setStageRect({
width: entry.contentRect.width,
height: entry.contentRect.height,
})
}
})
ro.observe(stage)
return () => ro.disconnect()
}, [])
// 监听视频元数据加载,获取真实画面宽高比
useEffect(() => {
const video = videoRef.current
if (!video) return
const updateRatio = () => {
if (video.videoWidth && video.videoHeight) {
setVideoRatio(video.videoWidth / video.videoHeight)
}
}
updateRatio()
video.addEventListener('loadedmetadata', updateRatio)
video.addEventListener('resize', updateRatio)
return () => {
video.removeEventListener('loadedmetadata', updateRatio)
video.removeEventListener('resize', updateRatio)
}
}, [videoRef, media])
// 点击视频切换播放/暂停;双击切换全屏(控制栏事件自行阻止冒泡)。
const togglePlay = () => {
const video = videoRef.current
@@ -58,24 +99,49 @@ export function PlayerVideoStage({
else video.pause()
}
const toggleFullscreen = () => {
const stage = videoRef.current?.parentElement
const stage = stageRef.current
if (!stage) return
if (document.fullscreenElement) void document.exitFullscreen()
else void stage.requestFullscreen?.()
}
// 把用户选择的字幕轨道应用到 <video> 的 textTracks(跨浏览器显式设置
// mode;<track default> 只影响初始值,部分浏览器不会自动显示)。
//
// 通过 React 渲染的 <track> 元素定位而不是 textTracks 索引:hls.js 等
// 可能向 textTracks 注入内部轨道(如 CEA-608 captions),索引会错位。
// 自定义字幕驱动逻辑:
// 把所选轨道设为 mode = 'hidden'(让浏览器在后台静默解析时间轴,但不渲染原生带黑底的字幕框),
// 由下方的 React 自定义层输出 100% 纯透明背景、高清晰文字阴影的字幕。
useEffect(() => {
const video = videoRef.current
if (!video || subs.length === 0) return
const timers = new Set<ReturnType<typeof setTimeout>>()
const delay = (fn: () => void, ms: number) => {
const id = setTimeout(fn, ms)
timers.add(id)
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 = () => {
@@ -83,72 +149,131 @@ export function PlayerVideoStage({
if (trackEls.length === 0) return
trackEls.forEach((el, i) => {
const tt = el.track
if (tt) tt.mode = i === subtitleIndex ? 'showing' : 'disabled'
if (tt) {
// 'hidden' 模式:浏览器解析 WebVTT 并触发 cuechange,但隐藏原生黑底 UI
tt.mode = i === subtitleIndex ? 'hidden' : 'disabled'
}
})
// Chrome 把轨道从 disabled 切回 showing 时是异步重新拉取字幕;
// 若拉取因竞态没有发生(例如切换过程中视频重载),cues 会一直为空,
// 表现为「字幕已选中但不显示」。检测到空轨道后强制重载一次兜底。
const selected = trackEls[subtitleIndex]
if (!selected) return
delay(() => {
const tt = selected.track
if (tt && tt.mode === 'showing' && (!tt.cues || tt.cues.length === 0)) {
const src = selected.getAttribute('src')
if (src) {
selected.setAttribute('src', '')
selected.setAttribute('src', src)
tt.mode = 'showing'
}
}
}, 1200)
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)
timers.forEach(clearTimeout)
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])
}, [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) => (
<track
key={track.path}
kind="subtitles"
src={subtitlesAPI.url(media.id, track.path)}
srcLang={track.lang}
label={track.label || track.lang}
/>
))}
</video>
<DanmakuStage
media={media}
videoRef={videoRef}
enabled={danmakuEnabled}
opacity={danmakuOpacity}
fontSize={danmakuFontSize}
area={danmakuArea}
search={danmakuSearch}
episodeId={danmakuEpisodeId}
onLoaded={onDanmakuLoaded}
onCandidates={onDanmakuCandidates}
/>
<video
ref={videoRef}
autoPlay
playsInline
className="h-full w-full object-contain bg-black"
onError={onVideoError}
>
{subs.map((track, 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}
@@ -170,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>
+63 -25
View File
@@ -96,7 +96,7 @@ function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
}
}
const batchBtn = (title: string, cls: string, onClick: () => void) => (
const batchBtn = (title: string, icon: 'trash' | 'ban' | 'refresh', cls: string, onClick: () => void) => (
<button
type="button"
disabled={batchBusy}
@@ -105,33 +105,58 @@ function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
'ml-auto inline-flex items-center gap-1 rounded-lg border px-2 py-1 text-xs font-semibold transition disabled:opacity-50 ' + cls
}
>
<Trash2 size={12} />
{icon === 'trash' && <Trash2 size={12} />}
{icon === 'ban' && <Ban size={12} />}
{icon === 'refresh' && <RefreshCw size={12} />}
{title}
</button>
)
const cancelAllPendingAction = () => {
const action = isDownload
? () => strmAPI.cancelPendingDownloads()
: () => strmAPI.cancelPendingUploads()
return runBatch(action, `确定取消所有排队中及进行中的${isDownload ? '下载' : '上传'}任务?`)
}
const batchActionByFilter = () => {
if (filter === 'pending' || filter === 'running')
return batchBtn(
'全部取消',
'ban',
'border-amber-200 text-amber-600 hover:bg-amber-50',
cancelAllPendingAction,
)
if (filter === 'canceled')
return batchBtn(
'清空已取消记录',
'trash',
'border-gray-200 text-rose-500 hover:bg-rose-50',
() =>
runBatch(
isDownload
? () => strmAPI.clearCanceledDownloads()
: () => strmAPI.clearCanceledUploads(),
`确定清空所有已取消的${isDownload ? '下载' : '上传'}记录?`,
),
)
if (!isDownload) return null
if (filter === 'done')
return batchBtn(
'清空成功记录',
'trash',
'border-gray-200 text-rose-500 hover:bg-rose-50',
() => runBatch(() => strmAPI.clearDoneDownloads(), '确定清空所有已完成下载记录?'),
)
if (filter === 'failed')
return batchBtn('批量重试', 'border-gray-200 text-brand-500 hover:bg-brand-50', () =>
return batchBtn('批量重试', 'refresh', 'border-gray-200 text-brand-500 hover:bg-brand-50', () =>
runBatch(() => strmAPI.retryFailedDownloads(), '确定重新入队所有失败下载任务?'),
)
if (filter === 'pending')
return batchBtn(
'批量取消',
'border-gray-200 text-amber-600 hover:bg-amber-50',
() => runBatch(() => strmAPI.cancelPendingDownloads(), '确定取消所有排队中的下载任务?'),
)
return null
}
const counts = snapshot?.counts
const activeTaskCount = (counts?.pending ?? 0) + (counts?.running ?? 0)
const tasks = snapshot?.tasks.filter((t) => filter === 'all' || t.status === filter) ?? []
const isDownload = kind === 'download'
const Icon = isDownload ? Download : Upload
@@ -152,23 +177,36 @@ function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
: 'STRM 元数据上传情况(本地 → 远端网盘,3 秒自动刷新)'}
</p>
</div>
{isDownload && (
<button
type="button"
disabled={batchBusy}
onClick={() =>
runBatch(() => strmAPI.clearFinishedDownloads(), '确定清空所有失败和成功的下载记录?')
}
className="inline-flex items-center gap-1.5 rounded-xl border border-rose-200 px-3 py-2 text-sm font-semibold text-rose-500 transition hover:bg-rose-50 disabled:opacity-50"
>
<Trash2 size={14} />
清空失败与完成记录
<div className="ml-auto flex items-center gap-2">
{activeTaskCount > 0 && (
<button
type="button"
disabled={batchBusy}
onClick={cancelAllPendingAction}
className="inline-flex items-center gap-1.5 rounded-xl border border-amber-300 bg-amber-500/10 px-3 py-2 text-sm font-semibold text-amber-600 transition hover:bg-amber-500/20 disabled:opacity-50"
>
<Ban size={14} />
全部取消 ({activeTaskCount})
</button>
)}
{isDownload && (
<button
type="button"
disabled={batchBusy}
onClick={() =>
runBatch(() => strmAPI.clearFinishedDownloads(), '确定清空所有失败和成功的下载记录?')
}
className="inline-flex items-center gap-1.5 rounded-xl border border-rose-200 px-3 py-2 text-sm font-semibold text-rose-500 transition hover:bg-rose-50 disabled:opacity-50"
>
<Trash2 size={14} />
清空完成与失败
</button>
)}
<button type="button" onClick={refresh} className="inline-flex items-center gap-1.5 rounded-xl border border-gray-200 px-3 py-2 text-sm font-semibold text-ink-100 hover:bg-gray-50">
<RefreshCw size={14} />
刷新
</button>
)}
<button type="button" onClick={refresh} className="ml-auto inline-flex items-center gap-1.5 rounded-xl border border-gray-200 px-3 py-2 text-sm font-semibold text-ink-100 hover:bg-gray-50">
<RefreshCw size={14} />
刷新
</button>
</div>
</header>
<div className="flex flex-wrap items-center gap-2">
+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