Files
MeBox/internal/service/strm_service.go
T
truewhile 97b3a7bd7f feat(emby): support multiple connection lines for remote mount (#9)
Add account-level multi-line configuration for emby_remote mounts.
Lines are stored in strm_accounts.config as a urls JSON array with
backward compatibility for the legacy single url field. The backend
automatically fails over to the next line on connection errors and
persists the working active_line index.

Frontend: multi-line editor on Emby mount page and STRM account dialog
with add/remove/reorder, line names, and URL validation.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 11:19:56 +08:00

961 lines
32 KiB
Go

// STRM 管理服务:网盘账号、同步目录、STRM 生成与元数据下载/上传队列。
//
// 设计参考 QMediaSync 的 STRM 同步:同步目录扫描网盘(115 / CloudDrive2 /
// OpenList,驱动复用 internal/service/cloud 包)或本地目录,视频文件生成
// 指向本服务播放端点的一行 URL 的 .strm 文件;元数据文件(nfo/图片/字幕)
// 经下载/上传队列与远端双向同步。
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service/cloud"
)
// strm 全局设置键(存于 Setting 表,strm.* 前缀)。
const (
StrmSettingBaseURL = "strm.base_url"
StrmSettingVideoExt = "strm.video_ext"
StrmSettingMetaExt = "strm.meta_ext"
StrmSettingExcludeName = "strm.exclude_name"
StrmSettingMinVideoSizeMB = "strm.min_video_size_mb"
StrmSettingAddPath = "strm.add_path"
StrmSettingDownloadMeta = "strm.download_meta"
StrmSettingUploadMeta = "strm.upload_meta"
StrmSettingDeleteDir = "strm.delete_dir"
StrmSettingDownloadThreads = "strm.download_threads"
StrmSettingUploadThreads = "strm.upload_threads"
)
const (
StrmDefaultVideoExt = "mkv,mp4,avi,rmvb,rm,mov,ts,wmv,flv,m4v,iso,mpg,mpeg,webm"
StrmDefaultMetaExt = "nfo,jpg,jpeg,png,srt,ass,ssa,sub,txt,bmp,webp"
StrmDefaultExclude = "sample,trailer,预告"
)
// StrmSettingDefs 是全局 strm 设置的默认值表,供设置对话框展示。
var StrmSettingDefs = map[string]struct {
Default string
Label string
Kind string // text / number / bool / choice
Choices []string
Help string
}{
StrmSettingBaseURL: {Default: "", Label: "STRM 链接基础地址", Kind: "text", Help: "生成的 strm 文件指向的播放地址(默认留空自动取服务器公网地址 app.server_url)。例如 http://192.168.1.10:8096"},
StrmSettingVideoExt: {Default: StrmDefaultVideoExt, Label: "视频扩展名", Kind: "text", Help: "逗号分隔;命中即生成 .strm,其余文件视为元数据"},
StrmSettingMetaExt: {Default: StrmDefaultMetaExt, Label: "元数据扩展名", Kind: "text", Help: "逗号分隔;进入下载/上传队列的文件类型(nfo/图片/字幕)"},
StrmSettingExcludeName: {Default: StrmDefaultExclude, Label: "排除文件名", Kind: "text", Help: "逗号分隔;文件名包含任一关键词即跳过"},
StrmSettingMinVideoSizeMB: {Default: "0", Label: "最小视频大小(MB)", Kind: "number", Help: "小于该大小的视频文件不生成 STRM,0 表示不限"},
StrmSettingAddPath: {Default: "1", Label: "STRM 链接 path 参数", Kind: "choice", Choices: []string{"1", "2", "3"}, Help: "1=附带完整远端路径 2=仅文件名 3=不带 path"},
StrmSettingDownloadMeta: {Default: "true", Label: "下载元数据", Kind: "bool", Help: "同步时把远端 nfo/图片/字幕下载到本地输出目录"},
StrmSettingUploadMeta: {Default: "false", Label: "上传元数据", Kind: "bool", Help: "同步时把本地元数据上传到远端(需网盘支持写入)"},
StrmSettingDeleteDir: {Default: "false", Label: "清理空目录", Kind: "bool", Help: "清理远端已删除的多余 .strm/元数据后,删除空目录"},
Strm115RelayKeySetting: {Default: "", Label: "115 中继授权共享密钥", Kind: "text", Help: "QMediaSync/MQFamily 中继授权的共享 AES 密钥(OAUTH_RELAY_ENCRYPTION_KEY);不配置则中继授权不可用"},
StrmSettingDownloadThreads: {Default: "3", Label: "下载队列线程数", Kind: "number", Help: "元数据下载并发数"},
StrmSettingUploadThreads: {Default: "2", Label: "上传队列线程数", Kind: "number", Help: "元数据上传并发数"},
}
// StrmAccountSecretKeys 是账号配置中需要加密存储的字段。
var StrmAccountSecretKeys = []string{"cookie", "password", "token", "access_token", "refresh_token", "api_key"}
// StrmService 提供 STRM 管理的能力。
type StrmService struct {
log *zap.Logger
repo *repository.Container
cfg *config.Config
crypto *CryptoService
http *http.Client
stopOnce sync.Once
stopCh chan struct{}
baseCtx context.Context // 服务级长期上下文(同步/队列不随 HTTP 请求取消)
mu sync.Mutex
running map[string]context.CancelFunc // sync path id -> cancel
oauthSessions map[string]*strm115AuthSession
wafUntil time.Time // 115 风控/限流熔断截止时间(由 mu 保护)
downloadSem chan struct{} // 全局下载并发信号量:限制整个进程同时进行「换直链+下载」的并发数
downloadSemOnce sync.Once
}
// strmWAFCooldown 检测到 115 风控/限流后下载队列的全局冷却时长。
const strmWAFCooldown = 3 * time.Minute
// strmDownloadSemCap 全局同时进行「换直链+下载」的并发上限。
//
// 115 对换直链接口(/open/ufile/downurl)风控极严:过去把全局 QPS 提到 8 或让多
// worker 高并发换链,会瞬时撞上 WAF 返回 405 阻断页并触发 180 秒冷却,反而更慢。
// 因此用信号量把整个进程同时换直链的并发数压到 3,与令牌桶限速共同兜底:
// 宁可下载稍慢,也绝不触发风控。下载本身走 CDN 不限速。
const strmDownloadSemCap = 3
// ensureDownloadSem 惰性初始化全局共享的下载并发信号量。
func (s *StrmService) ensureDownloadSem() {
s.downloadSemOnce.Do(func() {
s.downloadSem = make(chan struct{}, strmDownloadSemCap)
})
}
// acquireDownloadSlot 获取一个下载并发槽位(等待/取消安全)。
func (s *StrmService) acquireDownloadSlot(ctx context.Context) bool {
s.ensureDownloadSem()
select {
case s.downloadSem <- struct{}{}:
return true
case <-ctx.Done():
return false
}
}
// releaseDownloadSlot 释放一个下载并发槽位。
func (s *StrmService) releaseDownloadSlot() {
if s.downloadSem == nil {
return
}
<-s.downloadSem
}
// NewStrmService constructs the STRM service.
func NewStrmService(cfg *config.Config, log *zap.Logger, repos *repository.Container, crypto *CryptoService) *StrmService {
return &StrmService{
log: log,
repo: repos,
cfg: cfg,
crypto: crypto,
http: &http.Client{Timeout: 90 * time.Second},
stopCh: make(chan struct{}),
baseCtx: context.Background(),
running: map[string]context.CancelFunc{},
oauthSessions: map[string]*strm115AuthSession{},
}
}
// 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
}
if downloadThreads > 8 {
downloadThreads = 8
}
uploadThreads := s.strmIntSetting(ctx, StrmSettingUploadThreads, 2)
if uploadThreads < 1 {
uploadThreads = 1
}
if uploadThreads > 4 {
uploadThreads = 4
}
for i := 0; i < downloadThreads; i++ {
go s.downloadWorker(ctx)
}
for i := 0; i < uploadThreads; i++ {
go s.uploadWorker(ctx)
}
go s.cronLoop(ctx)
go s.queueCleanupLoop(ctx)
go s.refresh115TokensLoop(ctx)
s.log.Info("strm service started",
zap.Int("download_threads", downloadThreads),
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) })
}
// ─── 网盘账号 ──────────────────────────────────────────────────────────────────
// strmAccountConfigJSON 序列化账号配置并加密敏感字段。
func (s *StrmService) strmAccountConfigJSON(values map[string]string, encrypt bool) (string, error) {
cfg := make(map[string]string, len(values))
for k, v := range values {
if v == "" {
continue
}
if encrypt && strmContains(StrmAccountSecretKeys, k) {
cfg[k] = s.crypto.Encrypt(v)
} else {
cfg[k] = v
}
}
data, err := json.Marshal(cfg)
if err != nil {
return "", err
}
return string(data), nil
}
// strmAccountConfig 解密账号配置为驱动可直接消费的 map。
func (s *StrmService) strmAccountConfig(acct *model.StrmAccount) (map[string]string, error) {
cfg := map[string]string{}
if acct == nil || strings.TrimSpace(acct.Config) == "" {
return cfg, nil
}
if err := json.Unmarshal([]byte(acct.Config), &cfg); err != nil {
return nil, fmt.Errorf("decode account config: %w", err)
}
for _, k := range StrmAccountSecretKeys {
if v, ok := cfg[k]; ok {
cfg[k] = s.crypto.Decrypt(v)
}
}
return cfg, nil
}
// mergeEmbyRemoteConfig 对远程 Emby 账号配置做合并式更新:config 中出现的键
// 覆盖写入(敏感键按明文加密),未出现的键保留原密文;显式空字符串=清除。
func (s *StrmService) mergeEmbyRemoteConfig(existing string, config map[string]string) (string, error) {
out := map[string]string{}
if strings.TrimSpace(existing) != "" {
if err := json.Unmarshal([]byte(existing), &out); err != nil {
return "", fmt.Errorf("decode account config: %w", err)
}
}
for k, v := range config {
if v == "" {
delete(out, k)
continue
}
if strmContains(StrmAccountSecretKeys, k) {
out[k] = s.crypto.Encrypt(v)
} else {
out[k] = v
}
}
data, err := json.Marshal(out)
if err != nil {
return "", err
}
return string(data), nil
}
// HasStrmAccountCredential 报告账号是否已配置核心凭据(用于前端展示)。
func HasStrmAccountCredential(acct *model.StrmAccount) bool {
switch acct.Provider {
case model.StrmProvider115:
// 115 开放平台:已授权(含 access_token)才算配置完成
return strings.Contains(acct.Config, `"access_token"`)
case model.StrmProviderOpenList:
return strings.Contains(acct.Config, `"token"`) || strings.Contains(acct.Config, `"password"`)
case model.StrmProviderEmbyRemote:
// 远程 Emby:接入线路 + (自动认证凭据 或 手动 api_key) 即视为已配置。
hasURL := strings.Contains(acct.Config, `"url"`) || strings.Contains(acct.Config, `"urls"`)
return hasURL &&
(strings.Contains(acct.Config, `"token"`) || strings.Contains(acct.Config, `"api_key"`) ||
(strings.Contains(acct.Config, `"username"`) && strings.Contains(acct.Config, `"password"`)))
default:
return strings.Contains(acct.Config, `"password"`) || strings.Contains(acct.Config, `"token"`)
}
}
// CreateStrmAccount 创建网盘账号(校验提供方 + 凭据)。
func (s *StrmService) CreateStrmAccount(ctx context.Context, name, provider string, config map[string]string) (*model.StrmAccount, error) {
provider = strings.TrimSpace(provider)
if provider == "" || provider == model.StrmProviderLocal {
return nil, errors.New("请选择网盘类型")
}
if strings.TrimSpace(name) == "" {
name = providerLabel(provider)
}
enc, err := s.strmAccountConfigJSON(config, true)
if err != nil {
return nil, err
}
acct := &model.StrmAccount{
Name: strings.TrimSpace(name),
Provider: provider,
Config: enc,
Enabled: true,
}
if err := s.repo.StrmAccount.Create(ctx, acct); err != nil {
return nil, err
}
return acct, nil
}
// UpdateStrmAccount 更新账号;config 为空表示保留原凭据。
func (s *StrmService) UpdateStrmAccount(ctx context.Context, id, name string, enabled *bool, config map[string]string) (*model.StrmAccount, error) {
acct, err := s.repo.StrmAccount.FindByID(ctx, id)
if err != nil || acct == nil {
return nil, errNotFoundOr(err, "网盘账号不存在")
}
if strings.TrimSpace(name) != "" {
acct.Name = strings.TrimSpace(name)
}
if enabled != nil {
acct.Enabled = *enabled
}
if len(config) > 0 {
var enc string
var err error
if acct.Provider == model.StrmProviderEmbyRemote {
// 远程 Emby 账号:合并式更新。config 中出现的键覆盖(敏感键按明文
// 加密写入),未出现的键保留原密文——避免编辑「代理开关」时把已
// 保存的地址与凭据清空。
enc, err = s.mergeEmbyRemoteConfig(acct.Config, config)
} else {
enc, err = s.strmAccountConfigJSON(config, true)
}
if err != nil {
return nil, err
}
acct.Config = enc
}
if err := s.repo.StrmAccount.Update(ctx, acct); err != nil {
return nil, err
}
return acct, nil
}
// DeleteStrmAccount 删除账号;仍被同步目录引用时拒绝。
func (s *StrmService) DeleteStrmAccount(ctx context.Context, id string) error {
paths, err := s.repo.StrmSyncPath.List(ctx)
if err != nil {
return err
}
for _, p := range paths {
if p.AccountID == id {
return fmt.Errorf("该账号仍被同步目录「%s」引用,请先删除对应同步目录", p.Name)
}
}
if err := s.repo.StrmAccount.Delete(ctx, id); err != nil {
return err
}
return nil
}
// TestStrmAccount 连通性测试(Ping),结果写回账号。
func (s *StrmService) TestStrmAccount(ctx context.Context, id string) *model.StrmAccount {
acct, err := s.repo.StrmAccount.FindByID(ctx, id)
if err != nil || acct == nil {
return nil
}
now := time.Now()
acct.LastTestAt = &now
provider, err := s.providerFor(ctx, acct)
if err != nil {
acct.LastTestResult = err.Error()
acct.LastTestOK = false
} else if err := provider.Ping(ctx); err != nil {
acct.LastTestResult = err.Error()
acct.LastTestOK = false
} else {
acct.LastTestResult = "ok"
acct.LastTestOK = true
}
_ = s.repo.StrmAccount.Update(ctx, acct)
return acct
}
// ListAccounts 返回全部网盘账号。
func (s *StrmService) ListAccounts(ctx context.Context) ([]model.StrmAccount, error) {
return s.repo.StrmAccount.List(ctx)
}
// providerFor 依据账号配置构建网盘驱动。
func (s *StrmService) providerFor(ctx context.Context, acct *model.StrmAccount) (cloud.Provider, error) {
cfg, err := s.strmAccountConfig(acct)
if err != nil {
return nil, err
}
anyCfg := make(map[string]any, len(cfg)+1)
for k, v := range cfg {
anyCfg[k] = v
}
anyCfg["ua"] = defaultStrmUA
provider, err := cloud.New(acct.Provider, anyCfg, s.http)
if err != nil {
return nil, err
}
return provider, nil
}
// ─── 全局设置 ──────────────────────────────────────────────────────────────────
// GetStrmSettings 返回全局 strm 设置(含默认值)。
func (s *StrmService) GetStrmSettings(ctx context.Context) (map[string]string, error) {
out := map[string]string{}
for key, def := range StrmSettingDefs {
value, err := s.repo.Setting.Get(ctx, key)
if err != nil {
return nil, err
}
if strings.TrimSpace(value) == "" {
value = def.Default
}
out[key] = value
}
return out, nil
}
// UpdateStrmSettings 校验并保存全局 strm 设置。
func (s *StrmService) UpdateStrmSettings(ctx context.Context, values map[string]string) error {
for key, value := range values {
def, ok := StrmSettingDefs[key]
if !ok {
continue
}
value = strings.TrimSpace(value)
switch def.Kind {
case "number":
n, err := strconv.Atoi(value)
if err != nil || n < 0 {
return fmt.Errorf("%s 必须是正整数", def.Label)
}
case "bool":
if value != "true" && value != "false" {
return fmt.Errorf("%s 必须是 true/false", def.Label)
}
case "choice":
if !strmContains(def.Choices, value) {
return fmt.Errorf("%s 取值不合法", def.Label)
}
}
if err := s.repo.Setting.Set(ctx, key, value); err != nil {
return err
}
}
s.sync115RelayKey(ctx)
return nil
}
// strmSetting 读取单个 strm 设置(带默认值)。
func (s *StrmService) strmSetting(ctx context.Context, key string) string {
value, err := s.repo.Setting.Get(ctx, key)
if err == nil && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
def, ok := StrmSettingDefs[key]
if ok {
return def.Default
}
return ""
}
func (s *StrmService) strmIntSetting(ctx context.Context, key string, fallback int) int {
value := s.strmSetting(ctx, key)
if value == "" {
return fallback
}
n, err := strconv.Atoi(value)
if err != nil || n < 0 {
return fallback
}
return n
}
// ─── 同步目录 ──────────────────────────────────────────────────────────────────
// ListSyncPaths 返回全部同步目录。
func (s *StrmService) ListSyncPaths(ctx context.Context) ([]model.StrmSyncPath, error) {
return s.repo.StrmSyncPath.List(ctx)
}
// ListSyncRecords 返回同步记录。
func (s *StrmService) ListSyncRecords(ctx context.Context, pathID string, limit int) ([]model.StrmSyncRecord, error) {
return s.repo.StrmSyncRecord.List(ctx, pathID, limit)
}
// DeleteSyncRecord 删除单条同步记录。
func (s *StrmService) DeleteSyncRecord(ctx context.Context, id string) error {
if err := s.repo.StrmSyncRecord.Delete(ctx, id); err != nil {
return err
}
return nil
}
// ClearSyncRecords 清空某同步目录(pathID 为空则全部)的同步记录,返回删除条数。
func (s *StrmService) ClearSyncRecords(ctx context.Context, pathID string) (int64, error) {
if pathID != "" {
return s.repo.StrmSyncRecord.DeleteBySyncPathID(ctx, pathID)
}
var total int64
// 全量清空:分页拉取物理删除所有记录
for {
rows, err := s.repo.StrmSyncRecord.List(ctx, "", 200)
if err != nil {
return total, err
}
if len(rows) == 0 {
return total, nil
}
for _, rec := range rows {
if err := s.repo.StrmSyncRecord.Delete(ctx, rec.ID); err != nil {
return total, err
}
}
total += int64(len(rows))
}
}
// CreateSyncPath 校验并创建同步目录。
func (s *StrmService) CreateSyncPath(ctx context.Context, p *model.StrmSyncPath) (*model.StrmSyncPath, error) {
if err := s.validateSyncPath(ctx, p); err != nil {
return nil, err
}
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 表达式")
}
if err := s.repo.StrmSyncPath.Create(ctx, p); err != nil {
return nil, err
}
return p, nil
}
// UpdateSyncPath 更新同步目录(运行中禁止修改),保留同步状态字段。
func (s *StrmService) UpdateSyncPath(ctx context.Context, id string, p *model.StrmSyncPath) (*model.StrmSyncPath, error) {
existing, err := s.repo.StrmSyncPath.FindByID(ctx, id)
if err != nil || existing == nil {
return nil, errNotFoundOr(err, "同步目录不存在")
}
if s.IsSyncRunning(id) {
return nil, errors.New("该目录正在同步中,请先取消")
}
if err := s.validateSyncPath(ctx, p); err != nil {
return nil, err
}
p.ID = existing.ID
p.CreatedAt = existing.CreatedAt
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 表达式")
}
if err := s.repo.StrmSyncPath.Update(ctx, p); err != nil {
return nil, err
}
return p, nil
}
// DeleteSyncPath 删除同步目录(运行中禁止删除)。
func (s *StrmService) DeleteSyncPath(ctx context.Context, id string) error {
if s.IsSyncRunning(id) {
return errors.New("该目录正在同步中,请先取消")
}
return s.repo.StrmSyncPath.Delete(ctx, id)
}
// ─── 工具 ──────────────────────────────────────────────────────────────────────
func (s *StrmService) validateSyncPath(ctx context.Context, p *model.StrmSyncPath) error {
p.Provider = strings.TrimSpace(p.Provider)
if p.Provider == "" {
return errors.New("请选择同步类型")
}
if p.Provider == model.StrmProviderLocal {
p.AccountID = ""
if strings.TrimSpace(p.RemotePath) == "" {
return errors.New("本地同步需要填写源目录")
}
} else {
if strings.TrimSpace(p.AccountID) == "" {
return errors.New("请选择网盘账号")
}
acct, err := s.repo.StrmAccount.FindByID(ctx, p.AccountID)
if err != nil || acct == nil {
return errNotFoundOr(err, "网盘账号不存在")
}
if acct.Provider != p.Provider {
return errors.New("网盘账号类型与同步类型不一致")
}
}
if strings.TrimSpace(p.LocalPath) == "" {
return errors.New("请填写本地输出目录")
}
if err := ensureLocalDir(p.LocalPath); err != nil {
return fmt.Errorf("本地输出目录不可用:%w", err)
}
return nil
}
// strmDefaultBaseURL 兜底默认:所有地址配置都为空时使用本机监听地址。
func strmDefaultBaseURL(cfg *config.Config) string {
port := 8080
if cfg != nil && cfg.App.Port > 0 {
port = cfg.App.Port
}
return fmt.Sprintf("http://127.0.0.1:%d", port)
}
// strmEffectiveConfig 合并全局设置与同步目录覆盖,得出生效配置。
func (s *StrmService) strmEffectiveConfig(ctx context.Context, p *model.StrmSyncPath) (*strmPathConfig, error) {
cfg := &strmPathConfig{
BaseURL: firstNonEmpty(p.StrmBaseURL, s.strmSetting(ctx, StrmSettingBaseURL), PublicServerURL(ctx, s.repo, s.cfg), strmDefaultBaseURL(s.cfg)),
VideoExt: csvSplit(firstNonEmpty(p.VideoExt, s.strmSetting(ctx, StrmSettingVideoExt), StrmDefaultVideoExt)),
MetaExt: csvSplit(firstNonEmpty(p.MetaExt, s.strmSetting(ctx, StrmSettingMetaExt), StrmDefaultMetaExt)),
ExcludeName: csvSplit(firstNonEmpty(p.ExcludeName, s.strmSetting(ctx, StrmSettingExcludeName), StrmDefaultExclude)),
MinSize: p.MinVideoSizeMB * 1 << 20,
AddPath: p.AddPath,
}
if cfg.MinSize <= 0 && p.MinVideoSizeMB <= 0 {
m := s.strmIntSetting(ctx, StrmSettingMinVideoSizeMB, 0)
cfg.MinSize = int64(m) * 1 << 20
}
if cfg.AddPath < 1 || cfg.AddPath > 3 {
if v, err := strconv.Atoi(s.strmSetting(ctx, StrmSettingAddPath)); err == nil && v >= 1 && v <= 3 {
cfg.AddPath = v
} else {
cfg.AddPath = 1
}
}
// 目录级开关是具体值(前端默认从全局默认值带入),不再叠加全局设置
cfg.DownloadMeta = p.DownloadMeta
cfg.UploadMeta = p.UploadMeta
cfg.DeleteDir = p.DeleteDir
cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
return cfg, nil
}
func csvSplit(value string) []string {
parts := strings.Split(value, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
if p := strings.TrimSpace(strings.ToLower(part)); p != "" {
out = append(out, p)
}
}
return out
}
func strmContains(list []string, target string) bool {
for _, item := range list {
if item == target {
return true
}
}
return false
}
func errNotFoundOr(err error, msg string) error {
if err != nil {
return err
}
return errors.New(msg)
}
// providerLabel 提供方中文名(前端同名映射)。
func providerLabel(provider string) string {
switch provider {
case model.StrmProvider115:
return "115 网盘"
case model.StrmProviderCloudDrive:
return "CloudDrive2"
case model.StrmProviderOpenList:
return "OpenList"
case model.StrmProviderLocal:
return "本地目录"
case model.StrmProviderEmbyRemote:
return "Emby 远程挂载"
default:
return provider
}
}
// StrmProviderLabels 提供给方的展示标签。
var StrmProviderLabels = map[string]string{
model.StrmProvider115: "115 网盘",
model.StrmProviderCloudDrive: "CloudDrive2",
model.StrmProviderOpenList: "OpenList",
model.StrmProviderLocal: "本地目录",
model.StrmProviderEmbyRemote: "Emby 远程挂载",
}
const defaultStrmUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36 MMTL-Strm/1.0"
// ensureLocalDir 创建本地输出目录。
func ensureLocalDir(dir string) error {
return os.MkdirAll(sanitizeLocalPath(dir), 0o755)
}
// windowsReservedNames 包含 Windows 系统底层保留的设备名称(大小写不敏感)。
var windowsReservedNames = map[string]bool{
"CON": true, "PRN": true, "AUX": true, "NUL": true,
"COM1": true, "COM2": true, "COM3": true, "COM4": true, "COM5": true,
"COM6": true, "COM7": true, "COM8": true, "COM9": true,
"LPT1": true, "LPT2": true, "LPT3": true, "LPT4": true, "LPT5": true,
"LPT6": true, "LPT7": true, "LPT8": true, "LPT9": true,
}
func isWindowsReservedName(name string) bool {
return windowsReservedNames[strings.ToUpper(name)]
}
// truncateStringRuneSafe 安全截断 UTF-8 字符串至指定字节长度,不切断中文字符或多字节 rune。
func truncateStringRuneSafe(s string, maxBytes int) string {
if len(s) <= maxBytes {
return s
}
b := []byte(s)
if len(b) <= maxBytes {
return s
}
for maxBytes > 0 && (b[maxBytes]&0xC0 == 0x80) {
maxBytes--
}
return strings.TrimRight(string(b[:maxBytes]), ". ")
}
// cleanEntryName 清理单个目录名或文件名中的非法字符、控制字符、尾部点空格及 Windows 保留字,
// 确保在 Windows (NTFS/FAT)、Linux (ext4/btrfs/xfs) 及 NAS/SMB 挂载环境下均安全可用。
func cleanEntryName(name string, isDir bool) string {
name = strings.TrimSpace(name)
if name == "" {
return "unnamed"
}
if isDir {
clean := sanitizeFilename(name)
clean = strings.Trim(clean, ". ")
if clean == "" {
return "unnamed"
}
if isWindowsReservedName(clean) {
clean = "_" + clean
}
return truncateStringRuneSafe(clean, 255)
}
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
// 清洗扩展名中的非法字符
cleanExt := sanitizeFilename(ext)
cleanExt = strings.TrimSpace(cleanExt)
if cleanExt != "" && !strings.HasPrefix(cleanExt, ".") {
cleanExt = "." + cleanExt
}
cleanBase := sanitizeFilename(base)
cleanBase = strings.Trim(cleanBase, ". ")
if cleanBase == "" {
cleanBase = "unnamed"
}
if isWindowsReservedName(cleanBase) {
cleanBase = "_" + cleanBase
}
maxBaseBytes := 255 - len(cleanExt)
if maxBaseBytes < 10 {
maxBaseBytes = 255
cleanExt = ""
}
cleanBase = truncateStringRuneSafe(cleanBase, maxBaseBytes)
return cleanBase + cleanExt
}
// sanitizeRelativePath 清理相对路径中的非法字符与首尾点空格,确保跨操作系统路径合法。
func sanitizeRelativePath(rel string) string {
rel = strings.TrimSpace(rel)
if rel == "" {
return ""
}
rel = strings.ReplaceAll(rel, "\\", "/")
parts := strings.Split(rel, "/")
out := make([]string, 0, len(parts))
for i, part := range parts {
part = strings.TrimSpace(part)
if part == "" || part == "." || part == ".." {
continue
}
isDir := i < len(parts)-1
clean := cleanEntryName(part, isDir)
if clean == "" || clean == "." || clean == ".." {
continue
}
out = append(out, clean)
}
if len(out) == 0 {
return ""
}
return filepath.Join(out...)
}
// sanitizeLocalPath 对本地全路径中除根目录/盘符/UNC之外的各级目录及文件名进行跨平台非法字符清洗。
func sanitizeLocalPath(p string) string {
p = strings.TrimSpace(p)
if p == "" || p == "." {
return p
}
vol := filepath.VolumeName(p)
rest := p[len(vol):]
hasRootSlash := len(rest) > 0 && (rest[0] == '/' || rest[0] == '\\')
parts := strings.FieldsFunc(rest, func(r rune) bool {
return r == '/' || r == '\\'
})
cleanedParts := make([]string, 0, len(parts))
for i, part := range parts {
part = strings.TrimSpace(part)
if part == "" || part == "." || part == ".." {
continue
}
isDir := i < len(parts)-1
clean := cleanEntryName(part, isDir)
if clean == "" || clean == "." || clean == ".." {
continue
}
cleanedParts = append(cleanedParts, clean)
}
prefix := vol
if hasRootSlash {
prefix += string(filepath.Separator)
}
if len(cleanedParts) == 0 {
return filepath.Clean(prefix)
}
if prefix == "" {
return filepath.Clean(filepath.Join(cleanedParts...))
}
return filepath.Clean(filepath.Join(prefix, filepath.Join(cleanedParts...)))
}
// joinLocalRel 拼接本地目标路径并校验不越出根目录。
func joinLocalRel(root, rel string) (string, error) {
root = filepath.Clean(root)
cleanRel := sanitizeRelativePath(rel)
if cleanRel == "" {
return "", errors.New("无效相对路径")
}
target := filepath.Clean(filepath.Join(root, cleanRel))
relToRoot, err := filepath.Rel(root, target)
if err != nil || strings.HasPrefix(relToRoot, "..") || (relToRoot == "." && target != root) {
return "", errors.New("本地路径越界")
}
return target, nil
}
// strmPathConfig 是同步目录的生效配置快照。
type strmPathConfig struct {
BaseURL string
VideoExt []string
MetaExt []string
ExcludeName []string
MinSize int64
AddPath int
DownloadMeta bool
UploadMeta bool
DeleteDir bool
}
// ─── 本地目录浏览(添加同步目录用,兼容 Windows/Linux) ─────────────────────────
// StrmLocalDirEntry 是本地目录选择器的一个条目。
type StrmLocalDirEntry struct {
Name string `json:"name"`
Path string `json:"path"`
}
// StrmLocalDirList 是本地目录浏览结果。
type StrmLocalDirList struct {
Roots bool `json:"roots"` // true=正在显示根/盘符列表
Parent string `json:"parent,omitempty"` // 上级目录(为空表示没有)
Current string `json:"current,omitempty"` // 当前目录
Children []StrmLocalDirEntry `json:"children"`
}
// ListStrmLocalDirs 列出本地目录的子目录;path 为空时返回根/盘符列表。
func (s *StrmService) ListStrmLocalDirs(ctx context.Context, path string) (*StrmLocalDirList, error) {
path = strings.TrimSpace(path)
if path == "" {
if isWindows() {
// 列出存在的盘符
children := make([]StrmLocalDirEntry, 0, 4)
for _, letter := range "ABCDEFGHIJKLMNOPQRSTUVWXYZ" {
vol := string(letter) + ":\\"
if _, err := os.Stat(vol); err == nil {
children = append(children, StrmLocalDirEntry{Name: vol, Path: vol})
}
}
if len(children) == 0 {
children = append(children, StrmLocalDirEntry{Name: "C:\\", Path: "C:\\"})
}
return &StrmLocalDirList{Roots: true, Children: children}, nil
}
return &StrmLocalDirList{Roots: true, Children: []StrmLocalDirEntry{{Name: "/", Path: "/"}}}, nil
}
clean := filepath.Clean(path)
info, err := os.Stat(clean)
if err != nil {
return nil, fmt.Errorf("目录不可访问:%w", err)
}
if !info.IsDir() {
return nil, errors.New("所选路径不是目录")
}
entries, err := os.ReadDir(clean)
if err != nil {
return nil, fmt.Errorf("读取目录失败:%w", err)
}
children := make([]StrmLocalDirEntry, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
continue
}
if strings.HasPrefix(entry.Name(), ".") && entry.Name() != "." && entry.Name() != ".." {
continue // 隐藏目录不显示(避免噪音)
}
children = append(children, StrmLocalDirEntry{
Name: entry.Name(),
Path: filepath.Join(clean, entry.Name()),
})
}
sort.Slice(children, func(i, j int) bool { return children[i].Name < children[j].Name })
parent := filepath.Dir(clean)
if parent == clean {
parent = "" // 已到根/盘符根
}
return &StrmLocalDirList{Parent: parent, Current: clean, Children: children}, nil
}
func isWindows() bool {
return runtime.GOOS == "windows"
}