mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ab18c2725 | |||
| a4a2bde1a4 | |||
| 28113f5fdc | |||
| 42b8805e94 | |||
| 51a64f41b9 | |||
| a7bd9a942c | |||
| 97491a6175 | |||
| ea5cb3a130 | |||
| 921010926b | |||
| 7425c3d57b | |||
| 1b7d4eef46 | |||
| c30dab56a3 | |||
| e365250440 | |||
| 47d10e1f58 | |||
| e6473300a7 | |||
| 994f64f753 |
@@ -68,6 +68,7 @@ require (
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
github.com/ulikunitz/xz v0.5.12 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
|
||||
@@ -152,6 +152,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
||||
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
|
||||
@@ -3,8 +3,8 @@ package config
|
||||
import "github.com/spf13/viper"
|
||||
|
||||
const (
|
||||
defaultDatabaseMaxOpenConns = 4
|
||||
defaultDatabaseMaxIdleConns = 2
|
||||
defaultDatabaseMaxOpenConns = 16
|
||||
defaultDatabaseMaxIdleConns = 4
|
||||
defaultLicenseServerURL = "https://mgosever.3jzs.com"
|
||||
defaultLicensePublicKey = "MCowBQYDK2VwAyEABRXnXy+urjrbKit6Yu/HiezWgP0NdsZW3tsegJWRrtI="
|
||||
)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// SaveDatabaseConfig updates or creates config.yaml with the specified database configuration.
|
||||
func SaveDatabaseConfig(dbType, dsn string) error {
|
||||
configPath := "config.yaml"
|
||||
data := make(map[string]any)
|
||||
|
||||
content, err := os.ReadFile(configPath)
|
||||
if err == nil {
|
||||
if err := yaml.Unmarshal(content, &data); err != nil {
|
||||
data = make(map[string]any)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("read config.yaml: %w", err)
|
||||
}
|
||||
|
||||
dbSection, ok := data["database"].(map[string]any)
|
||||
if !ok {
|
||||
dbSection = make(map[string]any)
|
||||
}
|
||||
dbSection["type"] = dbType
|
||||
dbSection["dsn"] = dsn
|
||||
data["database"] = dbSection
|
||||
|
||||
out, err := yaml.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config.yaml: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(configPath, out, 0644); err != nil {
|
||||
return fmt.Errorf("write config.yaml: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSaveDatabaseConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
wd, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(wd) }()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
|
||||
dsn := "postgres://admin:pass@127.0.0.1:5432/mmtl?sslmode=disable"
|
||||
if err := SaveDatabaseConfig("postgres", dsn); err != nil {
|
||||
t.Fatalf("SaveDatabaseConfig error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(dir, "config.yaml")); err != nil {
|
||||
t.Fatalf("expected config.yaml to exist: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load error: %v", err)
|
||||
}
|
||||
if loaded.Database.Type != "postgres" {
|
||||
t.Fatalf("expected database.type=postgres, got %s", loaded.Database.Type)
|
||||
}
|
||||
if loaded.Database.DSN != dsn {
|
||||
t.Fatalf("expected dsn=%s, got %s", dsn, loaded.Database.DSN)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
)
|
||||
|
||||
// DatabaseStatus describes the currently active database engine and runtime metrics.
|
||||
type DatabaseStatus struct {
|
||||
Type string `json:"type"`
|
||||
DSN string `json:"dsn,omitempty"`
|
||||
DBPath string `json:"db_path,omitempty"`
|
||||
OpenConns int `json:"open_conns"`
|
||||
InUse int `json:"in_use"`
|
||||
Idle int `json:"idle"`
|
||||
MaxOpenConns int `json:"max_open_conns"`
|
||||
TableCounts map[string]int64 `json:"table_counts"`
|
||||
}
|
||||
|
||||
// PostgresTestResult returns latency and version info after testing connection.
|
||||
type PostgresTestResult struct {
|
||||
Success bool `json:"success"`
|
||||
LatencyMS int64 `json:"latency_ms"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DatabaseMigrationResult returns row counts and execution duration of migration.
|
||||
type DatabaseMigrationResult struct {
|
||||
Success bool `json:"success"`
|
||||
TotalRows int64 `json:"total_rows"`
|
||||
TableRows map[string]int64 `json:"table_rows"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// InspectDatabaseStatus queries the currently active database for metrics and table rows.
|
||||
func InspectDatabaseStatus(db *gorm.DB, cfg *config.Config) *DatabaseStatus {
|
||||
st := &DatabaseStatus{
|
||||
Type: "sqlite",
|
||||
TableCounts: make(map[string]int64),
|
||||
}
|
||||
if cfg != nil {
|
||||
st.DBPath = cfg.Database.DBPath
|
||||
if cfg.Database.Type == "postgres" || (cfg.Database.Type == "auto" && strings.TrimSpace(cfg.Database.DSN) != "") {
|
||||
st.Type = "postgres"
|
||||
st.DSN = MaskDSN(cfg.Database.DSN)
|
||||
}
|
||||
}
|
||||
if isPostgres(db) {
|
||||
st.Type = "postgres"
|
||||
}
|
||||
|
||||
if db != nil {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
stats := sqlDB.Stats()
|
||||
st.OpenConns = stats.OpenConnections
|
||||
st.InUse = stats.InUse
|
||||
st.Idle = stats.Idle
|
||||
st.MaxOpenConns = stats.MaxOpenConnections
|
||||
}
|
||||
|
||||
// Count rows for major model tables
|
||||
for _, m := range model.AllModels() {
|
||||
if tbl, err := modelTableName(db, m); err == nil {
|
||||
if db.Migrator().HasTable(tbl) {
|
||||
var count int64
|
||||
if err := db.Raw("SELECT COUNT(1) FROM " + quoteIdent(tbl)).Scan(&count).Error; err == nil {
|
||||
st.TableCounts[tbl] = count
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// TestPostgres establishes a temporary connection to verify reachability and permissions.
|
||||
func TestPostgres(dsn string) (*PostgresTestResult, error) {
|
||||
dsn = strings.TrimSpace(dsn)
|
||||
if dsn == "" {
|
||||
return &PostgresTestResult{
|
||||
Success: false,
|
||||
Error: "PostgreSQL DSN 不能为空",
|
||||
}, nil
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
testDB, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
return &PostgresTestResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("连接失败: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
sqlDB, err := testDB.DB()
|
||||
if err != nil {
|
||||
return &PostgresTestResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("获取底层连接失败: %v", err),
|
||||
}, nil
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := sqlDB.PingContext(ctx); err != nil {
|
||||
return &PostgresTestResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Ping 超时或失败: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var version string
|
||||
if err := testDB.WithContext(ctx).Raw("SELECT version()").Scan(&version).Error; err != nil {
|
||||
version = "PostgreSQL (unknown version)"
|
||||
}
|
||||
|
||||
latency := time.Since(start).Milliseconds()
|
||||
return &PostgresTestResult{
|
||||
Success: true,
|
||||
LatencyMS: latency,
|
||||
Version: version,
|
||||
Message: "连接成功",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MigrateCurrentToPostgres performs schema initialization and full table data copy into target PostgreSQL.
|
||||
func MigrateCurrentToPostgres(src *gorm.DB, targetDSN string, batchSize int, log *zap.Logger) (*DatabaseMigrationResult, error) {
|
||||
targetDSN = strings.TrimSpace(targetDSN)
|
||||
if targetDSN == "" {
|
||||
return nil, fmt.Errorf("target PostgreSQL DSN cannot be empty")
|
||||
}
|
||||
if src == nil {
|
||||
return nil, fmt.Errorf("current database is not available")
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
targetDB, err := gorm.Open(postgres.Open(targetDSN), &gorm.Config{
|
||||
Logger: newGormLogger(log),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open target PostgreSQL: %w", err)
|
||||
}
|
||||
targetSQLDB, err := targetDB.DB()
|
||||
if err == nil {
|
||||
defer targetSQLDB.Close()
|
||||
}
|
||||
|
||||
// 1. 初始化目标库 Schema、类型与索引
|
||||
if err := AutoMigrate(targetDB); err != nil {
|
||||
return nil, fmt.Errorf("auto migrate target PostgreSQL: %w", err)
|
||||
}
|
||||
|
||||
// 2. 安全重置目标数据库的初始默认数据
|
||||
if err := resetBootstrapTargetBeforeSQLiteMigrationIfSafe(src, targetDB, log); err != nil {
|
||||
return nil, fmt.Errorf("reset target bootstrap data: %w", err)
|
||||
}
|
||||
|
||||
// 3. 执行数据批量复制
|
||||
tableRows, totalRows, err := copyModelTables(src, targetDB, batchSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("copy tables: %w", err)
|
||||
}
|
||||
|
||||
// 4. 标记迁移完成
|
||||
if err := markSQLiteMigrationComplete(targetDB); err != nil {
|
||||
return nil, fmt.Errorf("mark migration complete: %w", err)
|
||||
}
|
||||
|
||||
duration := time.Since(started).Milliseconds()
|
||||
return &DatabaseMigrationResult{
|
||||
Success: true,
|
||||
TotalRows: totalRows,
|
||||
TableRows: tableRows,
|
||||
DurationMS: duration,
|
||||
Message: fmt.Sprintf("成功迁移 %d 条记录至 PostgreSQL", totalRows),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MaskDSN masks the password in a connection string for safe API responses.
|
||||
func MaskDSN(rawDSN string) string {
|
||||
rawDSN = strings.TrimSpace(rawDSN)
|
||||
if rawDSN == "" {
|
||||
return ""
|
||||
}
|
||||
if u, err := url.Parse(rawDSN); err == nil && u.User != nil {
|
||||
if pass, hasPassword := u.User.Password(); hasPassword && pass != "" {
|
||||
rawUserPass := u.User.String()
|
||||
user := u.User.Username()
|
||||
maskedUserPass := user + ":******"
|
||||
return strings.Replace(rawDSN, rawUserPass+"@", maskedUserPass+"@", 1)
|
||||
}
|
||||
}
|
||||
// Fallback for keyword-style DSN (e.g. host=... password=...)
|
||||
if strings.Contains(rawDSN, "password=") {
|
||||
parts := strings.Fields(rawDSN)
|
||||
for i, p := range parts {
|
||||
if strings.HasPrefix(p, "password=") {
|
||||
parts[i] = "password=******"
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
return rawDSN
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
)
|
||||
|
||||
func TestMaskDSN(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
in: "postgres://admin:secret123@localhost:5432/mmtl?sslmode=disable",
|
||||
want: "postgres://admin:******@localhost:5432/mmtl?sslmode=disable",
|
||||
},
|
||||
{
|
||||
in: "host=localhost port=5432 user=admin password=secret dbname=mmtl sslmode=disable",
|
||||
want: "host=localhost port=5432 user=admin password=****** dbname=mmtl sslmode=disable",
|
||||
},
|
||||
{
|
||||
in: "sqlite://data/mmtl.db",
|
||||
want: "sqlite://data/mmtl.db",
|
||||
},
|
||||
{
|
||||
in: "",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got := MaskDSN(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("MaskDSN(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectDatabaseStatus(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = db.Create(&model.User{Username: "testuser", PasswordHash: "h", Role: "user"}).Error
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.Database.Type = "sqlite"
|
||||
cfg.Database.DBPath = "./data/mmtl.db"
|
||||
|
||||
st := InspectDatabaseStatus(db, cfg)
|
||||
if st == nil {
|
||||
t.Fatal("expected non-nil DatabaseStatus")
|
||||
}
|
||||
if st.Type != "sqlite" {
|
||||
t.Fatalf("expected sqlite, got %s", st.Type)
|
||||
}
|
||||
if st.DBPath != "./data/mmtl.db" {
|
||||
t.Fatalf("expected db_path, got %s", st.DBPath)
|
||||
}
|
||||
if st.TableCounts["users"] != 1 {
|
||||
t.Fatalf("expected 1 user, got %d", st.TableCounts["users"])
|
||||
}
|
||||
}
|
||||
@@ -159,7 +159,7 @@ func TestCopyModelTablesMigratesExistingSQLiteRows(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
copied, err := copyModelTables(src, dst, 2)
|
||||
_, copied, err := copyModelTables(src, dst, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -222,7 +222,7 @@ func TestCopyModelTablesResumesPartialSQLiteMigration(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
copied, err := copyModelTables(src, dst, 2)
|
||||
_, copied, err := copyModelTables(src, dst, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -240,7 +240,7 @@ func TestCopyModelTablesResumesPartialSQLiteMigration(t *testing.T) {
|
||||
t.Fatalf("genres = %q, want %q", got.Genres, media.Genres)
|
||||
}
|
||||
|
||||
copied, err = copyModelTables(src, dst, 2)
|
||||
_, copied, err = copyModelTables(src, dst, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -332,7 +332,7 @@ func TestSQLiteMigrationFallsBackToDataDirDefaultPath(t *testing.T) {
|
||||
if err := resetBootstrapTargetBeforeSQLiteMigrationIfSafe(src2, dst, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
copied, err := copyModelTables(src2, dst, 2)
|
||||
_, copied, err := copyModelTables(src2, dst, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -408,13 +408,13 @@ func TestOpenSQLiteMigrationSourceUsesFallbackSourcePath(t *testing.T) {
|
||||
_ = sqlDB2.Close()
|
||||
}
|
||||
}()
|
||||
copied, err := copyModelTables(src2, dst, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if copied != 2 {
|
||||
t.Fatalf("copied rows = %d, want 2", copied)
|
||||
}
|
||||
_, copied, err := copyModelTables(src2, dst, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if copied != 2 {
|
||||
t.Fatalf("copied rows = %d, want 2", copied)
|
||||
}
|
||||
var userCount int64
|
||||
if err := dst.Model(&model.User{}).Where("username = ?", "real-admin").Count(&userCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -48,7 +48,7 @@ func MigrateSQLiteToCurrentIfNeeded(cfg *config.Config, target *gorm.DB, log *za
|
||||
if err := resetBootstrapTargetBeforeSQLiteMigrationIfSafe(src, target, log); err != nil {
|
||||
return err
|
||||
}
|
||||
copied, err := copyModelTables(src, target, 500)
|
||||
_, copied, err := copyModelTables(src, target, 500)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -13,52 +13,53 @@ import (
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
)
|
||||
|
||||
func copyModelTables(src, target *gorm.DB, batchSize int) (int64, error) {
|
||||
func copyModelTables(src, target *gorm.DB, batchSize int) (map[string]int64, int64, error) {
|
||||
if batchSize <= 0 {
|
||||
batchSize = 500
|
||||
}
|
||||
var copied int64
|
||||
tableCounts := make(map[string]int64)
|
||||
var totalCopied int64
|
||||
for _, m := range model.AllModels() {
|
||||
table, err := modelTableName(src, m)
|
||||
if err != nil {
|
||||
return copied, err
|
||||
return tableCounts, totalCopied, err
|
||||
}
|
||||
primaryColumns, err := modelPrimaryColumns(src, m)
|
||||
if err != nil {
|
||||
return copied, fmt.Errorf("inspect model %T primary keys: %w", m, err)
|
||||
return tableCounts, totalCopied, fmt.Errorf("inspect model %T primary keys: %w", m, err)
|
||||
}
|
||||
exists, err := sqliteTableExists(src, table)
|
||||
if err != nil {
|
||||
return copied, err
|
||||
return tableCounts, totalCopied, err
|
||||
}
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
var sourceCount int64
|
||||
if err := src.Raw("SELECT COUNT(1) FROM " + quoteIdent(table)).Scan(&sourceCount).Error; err != nil {
|
||||
return copied, fmt.Errorf("count sqlite table %s: %w", table, err)
|
||||
return tableCounts, totalCopied, fmt.Errorf("count sqlite table %s: %w", table, err)
|
||||
}
|
||||
if sourceCount == 0 {
|
||||
continue
|
||||
}
|
||||
var targetCount int64
|
||||
if err := target.Raw("SELECT COUNT(1) FROM " + quoteIdent(table)).Scan(&targetCount).Error; err != nil {
|
||||
return copied, fmt.Errorf("count target table %s: %w", table, err)
|
||||
return tableCounts, totalCopied, fmt.Errorf("count target table %s: %w", table, err)
|
||||
}
|
||||
modelType := reflect.TypeOf(m)
|
||||
if modelType.Kind() != reflect.Ptr {
|
||||
return copied, fmt.Errorf("model %T is not a pointer", m)
|
||||
return tableCounts, totalCopied, fmt.Errorf("model %T is not a pointer", m)
|
||||
}
|
||||
sliceType := reflect.SliceOf(modelType.Elem())
|
||||
slicePtr := reflect.New(sliceType)
|
||||
if err := src.Unscoped().Find(slicePtr.Interface()).Error; err != nil {
|
||||
return copied, fmt.Errorf("read sqlite table %s: %w", table, err)
|
||||
return tableCounts, totalCopied, fmt.Errorf("read sqlite table %s: %w", table, err)
|
||||
}
|
||||
filtered := slicePtr.Elem()
|
||||
if targetCount > 0 {
|
||||
primaryKeySet, err := targetPrimaryKeySet(target, table, primaryColumns)
|
||||
if err != nil {
|
||||
return copied, err
|
||||
return tableCounts, totalCopied, err
|
||||
}
|
||||
filtered = filterRowsMissingInTarget(target, table, primaryColumns, filtered, primaryKeySet)
|
||||
}
|
||||
@@ -68,11 +69,13 @@ func copyModelTables(src, target *gorm.DB, batchSize int) (int64, error) {
|
||||
filteredPtr := reflect.New(filtered.Type())
|
||||
filteredPtr.Elem().Set(filtered)
|
||||
if err := target.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(filteredPtr.Interface(), batchSize).Error; err != nil {
|
||||
return copied, fmt.Errorf("copy sqlite table %s: %w", table, err)
|
||||
return tableCounts, totalCopied, fmt.Errorf("copy sqlite table %s: %w", table, err)
|
||||
}
|
||||
copied += int64(filtered.Len())
|
||||
copiedForTable := int64(filtered.Len())
|
||||
tableCounts[table] = copiedForTable
|
||||
totalCopied += copiedForTable
|
||||
}
|
||||
return copied, nil
|
||||
return tableCounts, totalCopied, nil
|
||||
}
|
||||
|
||||
func modelPrimaryColumns(db *gorm.DB, m any) ([]string, error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -32,16 +33,37 @@ func installSQLiteWriteGate(db *gorm.DB) {
|
||||
gate.Unlock()
|
||||
}
|
||||
}
|
||||
rawLock := func(tx *gorm.DB) {
|
||||
if tx.Statement != nil && isReadOnlySQL(tx.Statement.SQL.String()) {
|
||||
return
|
||||
}
|
||||
lock(tx)
|
||||
}
|
||||
_ = db.Callback().Create().Before("gorm:create").Register("mmtl:sqlite_write_lock", lock)
|
||||
_ = db.Callback().Create().After("gorm:create").Register("mmtl:sqlite_write_unlock", unlock)
|
||||
_ = db.Callback().Update().Before("gorm:update").Register("mmtl:sqlite_write_lock", lock)
|
||||
_ = db.Callback().Update().After("gorm:update").Register("mmtl:sqlite_write_unlock", unlock)
|
||||
_ = db.Callback().Delete().Before("gorm:delete").Register("mmtl:sqlite_write_lock", lock)
|
||||
_ = db.Callback().Delete().After("gorm:delete").Register("mmtl:sqlite_write_unlock", unlock)
|
||||
_ = db.Callback().Raw().Before("gorm:raw").Register("mmtl:sqlite_write_lock", lock)
|
||||
_ = db.Callback().Raw().Before("gorm:raw").Register("mmtl:sqlite_write_lock", rawLock)
|
||||
_ = db.Callback().Raw().After("gorm:raw").Register("mmtl:sqlite_write_unlock", unlock)
|
||||
}
|
||||
|
||||
func isReadOnlySQL(sql string) bool {
|
||||
trimmed := strings.TrimSpace(sql)
|
||||
if len(trimmed) == 0 {
|
||||
return false
|
||||
}
|
||||
upper := strings.ToUpper(trimmed)
|
||||
if strings.HasPrefix(upper, "SELECT") || strings.HasPrefix(upper, "EXPLAIN") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(upper, "WITH") && !strings.Contains(upper, "INSERT") && !strings.Contains(upper, "UPDATE") && !strings.Contains(upper, "DELETE") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sqliteWriteGate serializes in-process SQLite writes while respecting the
|
||||
// statement context, so request cancellation can break out of a queued write.
|
||||
type sqliteWriteGate struct {
|
||||
@@ -84,7 +106,7 @@ func buildSQLiteDSN(cfg *config.Config) string {
|
||||
}
|
||||
dsn := dbPath + "?_pragma=foreign_keys(1)"
|
||||
if cfg.Database.WALMode {
|
||||
dsn += "&_pragma=journal_mode(WAL)"
|
||||
dsn += "&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)"
|
||||
}
|
||||
if cfg.Database.BusyTimeout > 0 {
|
||||
dsn += fmt.Sprintf("&_pragma=busy_timeout(%d)", cfg.Database.BusyTimeout)
|
||||
@@ -92,6 +114,7 @@ func buildSQLiteDSN(cfg *config.Config) string {
|
||||
if cfg.Database.CacheSize != 0 {
|
||||
dsn += fmt.Sprintf("&_pragma=cache_size(%d)", cfg.Database.CacheSize)
|
||||
}
|
||||
dsn += "&_pragma=temp_store(MEMORY)&_pragma=mmap_size(268435456)"
|
||||
return dsn
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
type DatabaseConnectionPayload struct {
|
||||
Type string `json:"type"`
|
||||
DSN string `json:"dsn"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
DBName string `json:"dbname"`
|
||||
SSLMode string `json:"sslmode"`
|
||||
}
|
||||
|
||||
func (p *DatabaseConnectionPayload) BuildDSN() string {
|
||||
raw := strings.TrimSpace(p.DSN)
|
||||
if raw != "" {
|
||||
return raw
|
||||
}
|
||||
host := strings.TrimSpace(p.Host)
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
port := p.Port
|
||||
if port <= 0 {
|
||||
port = 5432
|
||||
}
|
||||
user := strings.TrimSpace(p.User)
|
||||
dbname := strings.TrimSpace(p.DBName)
|
||||
if dbname == "" {
|
||||
dbname = "mmtl"
|
||||
}
|
||||
sslmode := strings.TrimSpace(p.SSLMode)
|
||||
if sslmode == "" {
|
||||
sslmode = "disable"
|
||||
}
|
||||
|
||||
userInfo := url.User(user)
|
||||
if p.Password != "" {
|
||||
userInfo = url.UserPassword(user, p.Password)
|
||||
}
|
||||
|
||||
u := url.URL{
|
||||
Scheme: "postgres",
|
||||
User: userInfo,
|
||||
Host: fmt.Sprintf("%s:%d", host, port),
|
||||
Path: "/" + dbname,
|
||||
RawQuery: "sslmode=" + url.QueryEscape(sslmode),
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func getDatabaseStatusHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc.Database == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database service unavailable"})
|
||||
return
|
||||
}
|
||||
status := svc.Database.GetStatus(c.Request.Context())
|
||||
c.JSON(http.StatusOK, status)
|
||||
}
|
||||
}
|
||||
|
||||
func testDatabaseHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req DatabaseConnectionPayload
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: " + err.Error()})
|
||||
return
|
||||
}
|
||||
dsn := req.BuildDSN()
|
||||
if dsn == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供有效的 PostgreSQL 连接信息或 DSN"})
|
||||
return
|
||||
}
|
||||
res, err := svc.Database.TestPostgres(c.Request.Context(), dsn)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
}
|
||||
}
|
||||
|
||||
func migrateDatabaseHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req DatabaseConnectionPayload
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: " + err.Error()})
|
||||
return
|
||||
}
|
||||
dsn := req.BuildDSN()
|
||||
if dsn == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供目标 PostgreSQL 连接信息或 DSN"})
|
||||
return
|
||||
}
|
||||
res, err := svc.Database.MigrateToPostgres(c.Request.Context(), dsn)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "迁移失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
}
|
||||
}
|
||||
|
||||
func saveDatabaseConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req DatabaseConnectionPayload
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: " + err.Error()})
|
||||
return
|
||||
}
|
||||
dbType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||
if dbType == "" {
|
||||
dbType = "postgres"
|
||||
}
|
||||
var dsn string
|
||||
if dbType == "postgres" {
|
||||
dsn = req.BuildDSN()
|
||||
if dsn == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供有效的 PostgreSQL 连接信息或 DSN"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := svc.Database.SaveConfig(c.Request.Context(), dbType, dsn); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "数据库配置已成功保存至配置文件,重启服务后将以新数据库运行",
|
||||
"type": dbType,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func TestBuildDSN(t *testing.T) {
|
||||
cases := []struct {
|
||||
payload DatabaseConnectionPayload
|
||||
want string
|
||||
}{
|
||||
{
|
||||
payload: DatabaseConnectionPayload{
|
||||
DSN: "postgres://myuser:mypass@10.0.0.1:5432/mydb?sslmode=require",
|
||||
},
|
||||
want: "postgres://myuser:mypass@10.0.0.1:5432/mydb?sslmode=require",
|
||||
},
|
||||
{
|
||||
payload: DatabaseConnectionPayload{
|
||||
Host: "127.0.0.1",
|
||||
Port: 5432,
|
||||
User: "postgres",
|
||||
Password: "secretpassword",
|
||||
DBName: "mmtl_prod",
|
||||
SSLMode: "disable",
|
||||
},
|
||||
want: "postgres://postgres:secretpassword@127.0.0.1:5432/mmtl_prod?sslmode=disable",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got := c.payload.BuildDSN()
|
||||
if got != c.want {
|
||||
t.Errorf("BuildDSN() = %q, want %q", got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDatabaseStatusHandler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cfg := &config.Config{}
|
||||
cfg.Database.Type = "sqlite"
|
||||
cfg.Database.DBPath = "./data/mmtl.db"
|
||||
|
||||
svc := &service.Container{
|
||||
Database: service.NewDatabaseAdminService(cfg, nil, nil, nil),
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/api/admin/database/status", getDatabaseStatusHandler(svc))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/database/status", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if resp["type"] != "sqlite" {
|
||||
t.Fatalf("expected type=sqlite, got %v", resp["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveDatabaseConfigHandler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
dir := t.TempDir()
|
||||
cfg := &config.Config{}
|
||||
cfg.App.DataDir = dir
|
||||
cfg.Database.Type = "sqlite"
|
||||
|
||||
svc := &service.Container{
|
||||
Database: service.NewDatabaseAdminService(cfg, nil, nil, nil),
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/api/admin/database/save-config", saveDatabaseConfigHandler(svc))
|
||||
|
||||
body := bytes.NewBufferString(`{"type":"postgres","host":"localhost","port":5432,"user":"admin","password":"pwd","dbname":"mmtl"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/database/save-config", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,22 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
libs = filtered
|
||||
}
|
||||
withPreview := c.Query("with_preview") == "1" || c.Query("with_preview") == "true"
|
||||
if withPreview {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("preview_limit", c.DefaultQuery("limit", "10")))
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
} else if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
previews, err := svc.Media.ListLibrariesWithPreview(c.Request.Context(), libs, mediaVisibilityForRequest(c, svc), limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, previews)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, libs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.
|
||||
registerAdminRecognitionWordRoutes(admin, svc)
|
||||
registerAdminStrmRoutes(admin, svc)
|
||||
registerAdminScraperRoutes(admin, svc)
|
||||
registerAdminDatabaseRoutes(admin, svc)
|
||||
|
||||
// FFmpeg/FFprobe 工具:状态查询 + 一键下载安装(自动匹配当前平台)。
|
||||
admin.GET("/tools/ffmpeg/status", ffToolsStatusHandler(svc))
|
||||
admin.POST("/tools/ffmpeg/install", ffToolsInstallHandler(svc))
|
||||
}
|
||||
|
||||
func registerAdminScraperRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
@@ -138,3 +143,10 @@ func registerAdminRecognitionWordRoutes(admin *gin.RouterGroup, svc *service.Con
|
||||
admin.POST("/recognition-words/sync", syncRecognitionWordsHandler(svc))
|
||||
admin.POST("/recognition-words/test", testRecognitionWordsHandler(svc))
|
||||
}
|
||||
|
||||
func registerAdminDatabaseRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.GET("/database/status", getDatabaseStatusHandler(svc))
|
||||
admin.POST("/database/test", testDatabaseHandler(svc))
|
||||
admin.POST("/database/migrate", migrateDatabaseHandler(svc))
|
||||
admin.POST("/database/save-config", saveDatabaseConfigHandler(svc))
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container
|
||||
|
||||
func registerAuthedMediaRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/media/:id", getMediaHandler(svc))
|
||||
authed.GET("/media/:id/episodes", listMediaEpisodesHandler(svc))
|
||||
authed.GET("/media", searchMediaHandler(svc))
|
||||
authed.PATCH("/media/:id/metadata", middleware.AdminRequired(), updateMediaMetadataHandler(svc))
|
||||
authed.POST("/media/:id/scrape", middleware.AdminRequired(), scrapeOneHandler(svc))
|
||||
|
||||
@@ -128,3 +128,19 @@ func listLibrarySeriesEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
|
||||
}
|
||||
}
|
||||
|
||||
func listMediaEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
|
||||
return
|
||||
}
|
||||
items, err := svc.Media.ListMediaEpisodes(c.Request.Context(), id, mediaVisibilityForRequest(c, svc))
|
||||
if err != nil {
|
||||
writeInternalOrCanceled(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func serveSubtitleHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "text/vtt; charset=utf-8")
|
||||
c.Header("Cache-Control", "public, max-age=3600")
|
||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
if err := svc.Subtitle.Serve(c.Request.Context(), c.Param("id"), path, c.Writer); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
// Package handler — system tools detection.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
// SystemHandler handles system-related endpoints.
|
||||
type SystemHandler struct {
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
svc *service.Container
|
||||
}
|
||||
|
||||
// NewSystemHandler is the constructor.
|
||||
func NewSystemHandler(cfg *config.Config, log *zap.Logger, svc *service.Container) *SystemHandler {
|
||||
return &SystemHandler{cfg: cfg, log: log, svc: svc}
|
||||
}
|
||||
|
||||
// ToolStatus represents the detection status of a system tool.
|
||||
type ToolStatus struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ConfigKey string `json:"config_key"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Detected bool `json:"detected"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
// GetToolsStatus returns the status of system tools.
|
||||
func (h *SystemHandler) GetToolsStatus(c *gin.Context) {
|
||||
tools := []ToolStatus{
|
||||
{Name: "ffprobe", DisplayName: "FFprobe", ConfigKey: "app.ffprobe_path"},
|
||||
{Name: "ffmpeg", DisplayName: "FFmpeg", ConfigKey: "app.ffmpeg_path"},
|
||||
}
|
||||
|
||||
for i := range tools {
|
||||
// Check configured path first
|
||||
var configuredPath string
|
||||
switch tools[i].ConfigKey {
|
||||
case "app.ffprobe_path":
|
||||
configuredPath = h.cfg.App.FFprobePath
|
||||
if configuredPath == "" {
|
||||
configuredPath = "ffprobe"
|
||||
}
|
||||
case "app.ffmpeg_path":
|
||||
configuredPath = h.cfg.App.FFmpegPath
|
||||
if configuredPath == "" {
|
||||
configuredPath = "ffmpeg"
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find the tool
|
||||
path, err := exec.LookPath(configuredPath)
|
||||
if err == nil {
|
||||
tools[i].Detected = true
|
||||
tools[i].Path = path
|
||||
// Try to get version
|
||||
tools[i].Version = getToolVersion(path)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tools": tools,
|
||||
})
|
||||
}
|
||||
|
||||
// getToolVersion attempts to get the version of a tool.
|
||||
func getToolVersion(path string) string {
|
||||
out, err := exec.Command(path, "-version").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Extract first line as version info
|
||||
lines := strings.Split(string(out), "\n")
|
||||
if len(lines) > 0 {
|
||||
return strings.TrimSpace(lines[0])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// InstallTools attempts to auto-install system tools (ffmpeg/ffprobe)
|
||||
func (h *SystemHandler) InstallTools(c *gin.Context) {
|
||||
h.log.Info("Received tools auto-install request")
|
||||
|
||||
// Call service layer to auto-install
|
||||
ffprobePath, ffmpegPath := service.AutoInstallFFmpeg(h.log, h.cfg)
|
||||
|
||||
result := gin.H{
|
||||
"installed": ffprobePath != "" || ffmpegPath != "",
|
||||
}
|
||||
|
||||
if ffprobePath != "" {
|
||||
result["ffprobe_path"] = ffprobePath
|
||||
result["ffprobe_installed"] = true
|
||||
}
|
||||
if ffmpegPath != "" {
|
||||
result["ffmpeg_path"] = ffmpegPath
|
||||
result["ffmpeg_installed"] = true
|
||||
}
|
||||
|
||||
// Re-detect tool status
|
||||
tools := []ToolStatus{
|
||||
{Name: "ffprobe", DisplayName: "FFprobe", ConfigKey: "app.ffprobe_path"},
|
||||
{Name: "ffmpeg", DisplayName: "FFmpeg", ConfigKey: "app.ffmpeg_path"},
|
||||
}
|
||||
|
||||
for i := range tools {
|
||||
var configuredPath string
|
||||
switch tools[i].ConfigKey {
|
||||
case "app.ffprobe_path":
|
||||
configuredPath = h.cfg.App.FFprobePath
|
||||
if configuredPath == "" {
|
||||
configuredPath = "ffprobe"
|
||||
}
|
||||
case "app.ffmpeg_path":
|
||||
configuredPath = h.cfg.App.FFmpegPath
|
||||
if configuredPath == "" {
|
||||
configuredPath = "ffmpeg"
|
||||
}
|
||||
}
|
||||
|
||||
path, err := exec.LookPath(configuredPath)
|
||||
if err == nil {
|
||||
tools[i].Detected = true
|
||||
tools[i].Path = path
|
||||
tools[i].Version = getToolVersion(path)
|
||||
}
|
||||
}
|
||||
|
||||
result["tools"] = tools
|
||||
|
||||
h.log.Info("Tool installation completed", zap.Any("result", result))
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Package handler — FFmpeg/FFprobe 工具安装端点。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
// ffToolsStatusHandler 返回 ffmpeg/ffprobe 当前安装状态
|
||||
// (GET /api/admin/tools/ffmpeg/status)。
|
||||
func ffToolsStatusHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc == nil || svc.FFTools == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "FFmpeg 工具服务不可用"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, svc.FFTools.Status(c.Request.Context()))
|
||||
}
|
||||
}
|
||||
|
||||
// ffToolsInstallHandler 触发后台下载安装(POST /api/admin/tools/ffmpeg/install)。
|
||||
// 自动匹配当前运行环境(OS+架构),安装到 data/tools/ffmpeg/ 并把路径写入设置。
|
||||
func ffToolsInstallHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc == nil || svc.FFTools == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "FFmpeg 工具服务不可用"})
|
||||
return
|
||||
}
|
||||
if err := svc.FFTools.StartInstall(c.Request.Context()); err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, svc.FFTools.Status(c.Request.Context()))
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,35 @@ func (r *MediaRepository) ListByLibrariesFiltered(ctx context.Context, libraryID
|
||||
return items, total, err
|
||||
}
|
||||
|
||||
type libraryCountRow struct {
|
||||
LibraryID string `gorm:"column:library_id"`
|
||||
Total int64 `gorm:"column:total"`
|
||||
}
|
||||
|
||||
// CountByLibraries returns a map of library_id -> total media count for the given library IDs.
|
||||
func (r *MediaRepository) CountByLibraries(ctx context.Context, libraryIDs []string, filter MediaQueryFilter) (map[string]int64, error) {
|
||||
out := make(map[string]int64, len(libraryIDs))
|
||||
if len(libraryIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
var rows []libraryCountRow
|
||||
q := r.db.WithContext(ctx).Model(&model.Media{}).
|
||||
Select("library_id, count(*) as total")
|
||||
if len(libraryIDs) == 1 {
|
||||
q = q.Where("library_id = ?", libraryIDs[0])
|
||||
} else {
|
||||
q = q.Where("library_id IN ?", libraryIDs)
|
||||
}
|
||||
q = applyMediaQueryFilter(q, filter)
|
||||
if err := q.Group("library_id").Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
out[row.LibraryID] = row.Total
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteByLibrary purges all media tied to a library.
|
||||
func (r *MediaRepository) DeleteByLibrary(ctx context.Context, libraryID string) error {
|
||||
// FTS 行由 media 表上的触发器同步清理(物理删除触发 FTS 清理)。
|
||||
|
||||
@@ -185,7 +185,7 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
|
||||
// refresh_token 刷新后重试一次。刷新失败或重试后仍失败才返回,
|
||||
// 避免长时间同步因 token 过期而整体失败。
|
||||
if isTokenCode(base.Code) {
|
||||
if access && c.tryRefreshTokenLocked() {
|
||||
if access && c.tryRefreshTokenLocked(ctx) {
|
||||
continue
|
||||
}
|
||||
if access {
|
||||
@@ -259,19 +259,43 @@ func (c *OpenClient) doAuthJSONWithUA(ctx context.Context, method, rawURL string
|
||||
}
|
||||
|
||||
// tryRefreshTokenLocked 并发安全地刷新 access_token;成功返回 true(调用方
|
||||
// 应使用内存中的新 token 重试原请求)。refresh_token 已失效时也会清空内存 token。
|
||||
func (c *OpenClient) tryRefreshTokenLocked() bool {
|
||||
// 应使用内存中的新 token 重试原请求)。
|
||||
//
|
||||
// 对"refresh_token 本身已失效/被吊销"(IsRefreshTokenDead,如 40140114/116/119/120)
|
||||
// 这类不可恢复的错误直接放弃并清空内存 token(提示需重新授权)。
|
||||
// 对其它失败(网络瞬时抖动、刷新接口可重试错误码等)做指数退避重试几次再放弃,
|
||||
// 避免同步长任务中途 token 到期时恰好撞上一个短暂的刷新失败就整体失败。
|
||||
func (c *OpenClient) tryRefreshTokenLocked(ctx context.Context) bool {
|
||||
c.tokenMu.Lock()
|
||||
defer c.tokenMu.Unlock()
|
||||
token, err := c.RefreshToken(c.RefreshTokenStr)
|
||||
if err != nil {
|
||||
for attempt := 0; attempt < refreshAttempts; attempt++ {
|
||||
token, err := c.RefreshToken(c.RefreshTokenStr)
|
||||
if err == nil {
|
||||
c.SetAuthToken(token.AccessToken, token.RefreshToken)
|
||||
return true
|
||||
}
|
||||
if IsRefreshTokenDead(err) {
|
||||
c.SetAuthToken("", "")
|
||||
return false
|
||||
}
|
||||
// 可恢复失败:退避后重试。ctx 取消时立即放弃。
|
||||
if attempt < refreshAttempts-1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-time.After(refreshBackoff(attempt)):
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
c.SetAuthToken(token.AccessToken, token.RefreshToken)
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
// refreshAttempts 是刷新 access_token 失败时的最大尝试次数(含首次)。
|
||||
const refreshAttempts = 3
|
||||
|
||||
// refreshBackoff 返回第 attempt 次(从 0 计)刷新失败后的退避时长(指数退避)。
|
||||
func refreshBackoff(attempt int) time.Duration {
|
||||
return time.Duration(200*(1<<attempt)) * time.Millisecond // 200ms, 400ms
|
||||
}
|
||||
|
||||
// IsThrottleCode 判断是否为限流错误码。
|
||||
|
||||
@@ -429,3 +429,46 @@ func TestRemoteFileDetailRelativePath(t *testing.T) {
|
||||
t.Errorf("d3.RelativePath = %q, want %q", got, "")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFsListRefreshContinue 验证 access_token 在请求中途过期(40140126)时:
|
||||
// 自动用 refresh_token 刷新得到新 token,然后对原请求重试成功(同步得以继续)。
|
||||
func TestFsListRefreshContinue(t *testing.T) {
|
||||
var filesCalls int
|
||||
var refreshCalls int
|
||||
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/open/refreshToken":
|
||||
refreshCalls++
|
||||
w.Write([]byte(`{"state":true,"data":{"access_token":"at2","refresh_token":"rt2","expires_in":7200}}`))
|
||||
case "/open/ufile/files":
|
||||
filesCalls++
|
||||
switch filesCalls {
|
||||
case 1:
|
||||
// 第一次用旧 access_token,返回过期错误,应触发刷新
|
||||
w.Write([]byte(`{"state":false,"code":40140126,"message":"access_token 校验失败"}`))
|
||||
default:
|
||||
// 刷新后续请求应使用新 access_token
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer at2" {
|
||||
t.Errorf("retried request auth = %q, want Bearer at2", got)
|
||||
}
|
||||
w.Write([]byte(`{"state":true,"path":[],"data":[{"fid":"200","fc":"1","fn":"a.mkv","fs":123,"pc":"pickA"}]}`))
|
||||
}
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
})
|
||||
c := NewOpenClient("100195125", "at1", "rt1")
|
||||
files, _, err := c.GetFsList(context.Background(), "0", 0, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("expected sync to continue after refresh, got error: %v", err)
|
||||
}
|
||||
if filesCalls != 2 {
|
||||
t.Fatalf("want 2 files calls (original + retried), got %d", filesCalls)
|
||||
}
|
||||
if refreshCalls == 0 {
|
||||
t.Fatal("expected refresh_token to be used once")
|
||||
}
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("want 1 file, got %d", len(files))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/database"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
)
|
||||
|
||||
// DatabaseAdminService manages database configuration, connectivity testing, and migration.
|
||||
type DatabaseAdminService struct {
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repos *repository.Container
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewDatabaseAdminService creates a new DatabaseAdminService.
|
||||
func NewDatabaseAdminService(cfg *config.Config, log *zap.Logger, repos *repository.Container, db *gorm.DB) *DatabaseAdminService {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
return &DatabaseAdminService{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
repos: repos,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// GetStatus returns the status of the currently active database.
|
||||
func (s *DatabaseAdminService) GetStatus(ctx context.Context) *database.DatabaseStatus {
|
||||
return database.InspectDatabaseStatus(s.db, s.cfg)
|
||||
}
|
||||
|
||||
// TestPostgres verifies connectivity and permissions to the specified PostgreSQL DSN.
|
||||
func (s *DatabaseAdminService) TestPostgres(ctx context.Context, dsn string) (*database.PostgresTestResult, error) {
|
||||
return database.TestPostgres(dsn)
|
||||
}
|
||||
|
||||
// MigrateToPostgres copies all records from the current active database to the target PostgreSQL database.
|
||||
func (s *DatabaseAdminService) MigrateToPostgres(ctx context.Context, targetDSN string) (*database.DatabaseMigrationResult, error) {
|
||||
s.log.Info("starting user-initiated database migration to PostgreSQL", zap.String("target", database.MaskDSN(targetDSN)))
|
||||
res, err := database.MigrateCurrentToPostgres(s.db, targetDSN, 500, s.log)
|
||||
if err != nil {
|
||||
s.log.Error("database migration to PostgreSQL failed", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
s.log.Info("database migration to PostgreSQL completed successfully",
|
||||
zap.Int64("total_rows", res.TotalRows),
|
||||
zap.Int64("duration_ms", res.DurationMS),
|
||||
)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// SaveConfig persists the database configuration to config.yaml and the database settings table.
|
||||
func (s *DatabaseAdminService) SaveConfig(ctx context.Context, dbType, dsn string) error {
|
||||
dbType = strings.TrimSpace(dbType)
|
||||
dsn = strings.TrimSpace(dsn)
|
||||
if dbType == "" {
|
||||
dbType = "postgres"
|
||||
}
|
||||
if dbType == "postgres" && dsn == "" {
|
||||
return fmt.Errorf("PostgreSQL DSN 不能为空")
|
||||
}
|
||||
|
||||
// 1. 保存到本地 config.yaml
|
||||
if err := config.SaveDatabaseConfig(dbType, dsn); err != nil {
|
||||
return fmt.Errorf("保存配置文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 2. 更新内存配置
|
||||
s.cfg.Database.Type = dbType
|
||||
s.cfg.Database.DSN = dsn
|
||||
|
||||
// 3. 同时更新 settings 存储库作为副本
|
||||
if s.repos != nil && s.repos.Setting != nil {
|
||||
_ = s.repos.Setting.Set(ctx, "database.type", dbType)
|
||||
_ = s.repos.Setting.Set(ctx, "database.dsn", dsn)
|
||||
}
|
||||
|
||||
s.log.Info("database configuration saved", zap.String("type", dbType), zap.String("dsn", database.MaskDSN(dsn)))
|
||||
return nil
|
||||
}
|
||||
@@ -199,7 +199,9 @@ func (e *EmbyService) appendSubtitleStreams(ctx context.Context, streams []map[s
|
||||
if e == nil || e.subtitle == nil || m == nil {
|
||||
return streams
|
||||
}
|
||||
tracks, err := e.subtitle.Discover(ctx, m.ID)
|
||||
// Emby 字幕只列表外挂字幕文件:云盘/strm 媒体的容器内嵌字幕不做服务端
|
||||
// 提取,客户端直连播放直链时自行解析。
|
||||
tracks, err := e.subtitle.DiscoverExternalOnly(ctx, m.ID)
|
||||
if err != nil || len(tracks) == 0 {
|
||||
return streams
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string)
|
||||
// so the DeliveryUrl advertised in MediaStreams lines up exactly with the
|
||||
// served track: subtitles start at 1 when no audio stream is present, otherwise
|
||||
// at 2 (after Video 0 + Audio 1).
|
||||
//
|
||||
// 只服务外挂字幕文件(DiscoverExternalOnly):云盘/strm 媒体的容器内嵌字幕
|
||||
// 不做服务端提取,客户端直连播放直链时自行解析。
|
||||
func (e *EmbyService) ServeSubtitleStream(ctx context.Context, w io.Writer, mediaID, indexStr string, userID string) error {
|
||||
if e == nil || e.subtitle == nil {
|
||||
return ErrSubtitleUnavailable
|
||||
@@ -44,7 +47,7 @@ func (e *EmbyService) ServeSubtitleStream(ctx context.Context, w io.Writer, medi
|
||||
if err != nil || index < 1 {
|
||||
return ErrSubtitleNotFound
|
||||
}
|
||||
tracks, err := e.subtitle.Discover(ctx, m.ID)
|
||||
tracks, err := e.subtitle.DiscoverExternalOnly(ctx, m.ID)
|
||||
if err != nil || len(tracks) == 0 {
|
||||
return ErrSubtitleNotFound
|
||||
}
|
||||
@@ -74,7 +77,7 @@ func (e *EmbyService) SubtitleStreamCodec(ctx context.Context, mediaID, indexStr
|
||||
if err != nil || index < 1 {
|
||||
return ""
|
||||
}
|
||||
tracks, err := e.subtitle.Discover(ctx, m.ID)
|
||||
tracks, err := e.subtitle.DiscoverExternalOnly(ctx, m.ID)
|
||||
if err != nil || len(tracks) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1,196 +1,358 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ulikunitz/xz"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
)
|
||||
|
||||
// AutoInstallFFmpeg is only called by the admin tool-install endpoint. The
|
||||
// server must not auto-download or keep ffmpeg/ffprobe running during startup.
|
||||
func AutoInstallFFmpeg(log *zap.Logger, cfg *config.Config) (ffprobePath, ffmpegPath string) {
|
||||
// 1. 优先使用配置 / PATH / 本机常见软件目录中的现有工具。
|
||||
if path, err := resolveLocalExecutable(cfg.App.FFprobePath, "ffprobe"); err == nil {
|
||||
ffprobePath = path
|
||||
cfg.App.FFprobePath = path
|
||||
log.Info("found local ffprobe", zap.String("path", path))
|
||||
}
|
||||
if path, err := resolveLocalExecutable(cfg.App.FFmpegPath, "ffmpeg"); err == nil {
|
||||
ffmpegPath = path
|
||||
cfg.App.FFmpegPath = path
|
||||
log.Info("found local ffmpeg", zap.String("path", path))
|
||||
}
|
||||
if ffprobePath != "" || ffmpegPath != "" {
|
||||
return ffprobePath, ffmpegPath
|
||||
}
|
||||
// ffmpegDownloadTarget 描述某个平台对应的官方构建下载源。
|
||||
type ffmpegDownloadTarget struct {
|
||||
Label string // 展示名,如 "Windows x86_64"
|
||||
Kind string // 压缩包类型:zip / tar.xz
|
||||
Archives []string // 依次尝试的下载地址(主源 + 备用源)
|
||||
}
|
||||
|
||||
// 2. 检查默认安装位置。
|
||||
defaultDir := getDefaultInstallDir()
|
||||
ffprobeDefault := filepath.Join(defaultDir, "bin", "ffprobe.exe")
|
||||
ffmpegDefault := filepath.Join(defaultDir, "bin", "ffmpeg.exe")
|
||||
|
||||
if _, err := os.Stat(ffprobeDefault); err == nil {
|
||||
log.Info("在默认位置找到 ffprobe", zap.String("path", ffprobeDefault))
|
||||
return ffprobeDefault, ffmpegDefault
|
||||
}
|
||||
|
||||
// 3. 尝试自动安装。
|
||||
log.Warn("未找到 ffmpeg/ffprobe,尝试自动安装...")
|
||||
installed, err := tryAutoInstall(log, defaultDir)
|
||||
if err != nil {
|
||||
log.Error("自动安装失败,请手动安装 ffmpeg", zap.Error(err))
|
||||
return "", ""
|
||||
}
|
||||
|
||||
if installed {
|
||||
if _, err := os.Stat(ffprobeDefault); err == nil {
|
||||
log.Info("自动安装成功", zap.String("path", ffprobeDefault))
|
||||
// 更新配置
|
||||
updateConfigPaths(cfg, ffprobeDefault, ffmpegDefault)
|
||||
return ffprobeDefault, ffmpegDefault
|
||||
// ffmpegTargetForPlatform 按当前运行环境(OS+架构)选择下载源。
|
||||
func ffmpegTargetForPlatform() (*ffmpegDownloadTarget, error) {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
return &ffmpegDownloadTarget{
|
||||
Label: "Windows x86_64",
|
||||
Kind: "zip",
|
||||
Archives: []string{
|
||||
"https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip",
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip",
|
||||
},
|
||||
}, nil
|
||||
case "386":
|
||||
return &ffmpegDownloadTarget{
|
||||
Label: "Windows x86",
|
||||
Kind: "zip",
|
||||
Archives: []string{
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win32-gpl.zip",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
case "linux":
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
return &ffmpegDownloadTarget{
|
||||
Label: "Linux x86_64",
|
||||
Kind: "tar.xz",
|
||||
Archives: []string{
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz",
|
||||
"https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz",
|
||||
},
|
||||
}, nil
|
||||
case "arm64":
|
||||
return &ffmpegDownloadTarget{
|
||||
Label: "Linux ARM64",
|
||||
Kind: "tar.xz",
|
||||
Archives: []string{
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linuxarm64-gpl.tar.xz",
|
||||
"https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz",
|
||||
},
|
||||
}, nil
|
||||
case "arm":
|
||||
return &ffmpegDownloadTarget{
|
||||
Label: "Linux ARM (32 位)",
|
||||
Kind: "tar.xz",
|
||||
Archives: []string{
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linuxarmhf-gpl.tar.xz",
|
||||
},
|
||||
}, nil
|
||||
case "loong64":
|
||||
return &ffmpegDownloadTarget{
|
||||
Label: "Linux LoongArch64",
|
||||
Kind: "tar.xz",
|
||||
Archives: []string{
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linuxloongarch64-gpl.tar.xz",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
case "darwin":
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
return &ffmpegDownloadTarget{
|
||||
Label: "macOS x86_64",
|
||||
Kind: "zip",
|
||||
Archives: []string{
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-osx64-gpl.zip",
|
||||
},
|
||||
}, nil
|
||||
case "arm64":
|
||||
return &ffmpegDownloadTarget{
|
||||
Label: "macOS Apple Silicon",
|
||||
Kind: "zip",
|
||||
Archives: []string{
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-osxarm64-gpl.zip",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", ""
|
||||
return nil, fmt.Errorf("暂不支持自动下载的平台 %s/%s,请手动填写 ffmpeg/ffprobe 路径", runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
|
||||
// getDefaultInstallDir 返回默认安装目录
|
||||
func getDefaultInstallDir() string {
|
||||
exePath, err := os.Executable()
|
||||
// installFFmpegTools 按平台下载并安装 ffmpeg/ffprobe 到 data/tools/ffmpeg/,
|
||||
// 返回两个可执行文件的绝对路径。progress 用于回传阶段消息(UI 展示)。
|
||||
func installFFmpegTools(ctx context.Context, log *zap.Logger, cfg *config.Config, progress func(string)) (ffmpegPath, ffprobePath string, err error) {
|
||||
target, err := ffmpegTargetForPlatform()
|
||||
if err != nil {
|
||||
return "./tools/ffmpeg"
|
||||
return "", "", err
|
||||
}
|
||||
exeDir := filepath.Dir(exePath)
|
||||
return filepath.Join(exeDir, "tools", "ffmpeg")
|
||||
}
|
||||
|
||||
// tryAutoInstall 尝试自动下载并安装 ffmpeg
|
||||
func tryAutoInstall(log *zap.Logger, installDir string) (bool, error) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return downloadFFmpegWindows(log, installDir)
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("不支持的操作系统: %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
// downloadFFmpegWindows 下载 Windows 版本的 ffmpeg
|
||||
func downloadFFmpegWindows(log *zap.Logger, installDir string) (bool, error) {
|
||||
log.Info("开始下载 ffmpeg...")
|
||||
|
||||
// 创建安装目录
|
||||
installDir := filepath.Join(cfg.App.DataDir, "tools", "ffmpeg")
|
||||
if err := os.MkdirAll(installDir, 0o750); err != nil {
|
||||
return false, fmt.Errorf("创建安装目录失败: %w", err)
|
||||
return "", "", fmt.Errorf("创建安装目录失败: %w", err)
|
||||
}
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "mmtl-ffmpeg-*")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("创建临时目录失败: %w", err)
|
||||
return "", "", fmt.Errorf("创建临时目录失败: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// 下载 URL (使用 gyani.org 的静态构建)
|
||||
arch := "win64"
|
||||
if !is64Bit() {
|
||||
arch = "win32"
|
||||
progress("下载 " + target.Label + " 版本…")
|
||||
archivePath := filepath.Join(tempDir, "ffmpeg-archive."+target.Kind)
|
||||
if err := downloadFFmpegArchive(ctx, log, target.Archives, archivePath); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// 先尝试从 gyan.dev 下载(更可靠)
|
||||
downloadURL := fmt.Sprintf("https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip")
|
||||
progress("解压…")
|
||||
extractDir := filepath.Join(tempDir, "extract")
|
||||
if err := extractFFmpegArchive(target.Kind, archivePath, extractDir); err != nil {
|
||||
return "", "", fmt.Errorf("解压失败: %w", err)
|
||||
}
|
||||
|
||||
log.Info("下载 ffmpeg", zap.String("url", downloadURL))
|
||||
exeSuffix := ""
|
||||
if runtime.GOOS == "windows" {
|
||||
exeSuffix = ".exe"
|
||||
}
|
||||
srcFFmpeg, srcFFprobe, err := locateFFmpegBinaries(extractDir, exeSuffix)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// 使用 Go 下载
|
||||
zipPath := filepath.Join(tempDir, "ffmpeg.zip")
|
||||
if err := downloadFile(log, downloadURL, zipPath); err != nil {
|
||||
// 尝试备用 URL
|
||||
backupURL := fmt.Sprintf("https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-%s-gpl.zip", arch)
|
||||
log.Info("尝试备用下载地址", zap.String("url", backupURL))
|
||||
if err2 := downloadFile(log, backupURL, zipPath); err2 != nil {
|
||||
return false, fmt.Errorf("下载失败: %v, %v", err, err2)
|
||||
progress("安装到 data 目录…")
|
||||
ffmpegPath = filepath.Join(installDir, "ffmpeg"+exeSuffix)
|
||||
ffprobePath = filepath.Join(installDir, "ffprobe"+exeSuffix)
|
||||
if err := copyFileMode(srcFFmpeg, ffmpegPath); err != nil {
|
||||
return "", "", fmt.Errorf("复制 ffmpeg 失败: %w", err)
|
||||
}
|
||||
if err := copyFileMode(srcFFprobe, ffprobePath); err != nil {
|
||||
_ = os.Remove(ffmpegPath)
|
||||
return "", "", fmt.Errorf("复制 ffprobe 失败: %w", err)
|
||||
}
|
||||
|
||||
// 验证两个工具都能运行(失败则回滚,避免留下坏文件)。
|
||||
for _, bin := range []string{ffmpegPath, ffprobePath} {
|
||||
cmd := exec.CommandContext(ctx, bin, "-version") // #nosec G204 -- bin 是安装目录中刚写入的固定文件名。
|
||||
if out, verr := cmd.Output(); verr != nil {
|
||||
_ = os.Remove(ffmpegPath)
|
||||
_ = os.Remove(ffprobePath)
|
||||
return "", "", fmt.Errorf("安装后 %s 无法运行:%v", filepath.Base(bin), verr)
|
||||
} else if log != nil {
|
||||
log.Info("ffmpeg 工具安装验证通过", zap.String("bin", filepath.Base(bin)),
|
||||
zap.String("version", strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])))
|
||||
}
|
||||
}
|
||||
|
||||
// 解压
|
||||
log.Info("解压 ffmpeg...")
|
||||
extractDir := filepath.Join(tempDir, "extract")
|
||||
if err := unzip(log, zipPath, extractDir); err != nil {
|
||||
return false, fmt.Errorf("解压失败: %w", err)
|
||||
}
|
||||
|
||||
packageRoot, err := findFFmpegPackageRoot(extractDir)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := copyDirContents(packageRoot, installDir); err != nil {
|
||||
return false, fmt.Errorf("复制 ffmpeg 文件失败: %w", err)
|
||||
}
|
||||
|
||||
ffmpegBin := filepath.Join(installDir, "bin", "ffmpeg.exe")
|
||||
ffprobeBin := filepath.Join(installDir, "bin", "ffprobe.exe")
|
||||
if _, err := os.Stat(ffmpegBin); err != nil {
|
||||
return false, fmt.Errorf("安装后未找到 ffmpeg: %w", err)
|
||||
}
|
||||
if _, err := os.Stat(ffprobeBin); err != nil {
|
||||
return false, fmt.Errorf("安装后未找到 ffprobe: %w", err)
|
||||
}
|
||||
|
||||
log.Info("ffmpeg 安装完成", zap.String("dir", installDir))
|
||||
return true, nil
|
||||
progress("安装完成")
|
||||
return ffmpegPath, ffprobePath, nil
|
||||
}
|
||||
|
||||
// downloadFile 下载文件
|
||||
func downloadFile(log *zap.Logger, url, filepath string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
// downloadFFmpegArchive 按顺序尝试下载源,全部失败才返回错误。
|
||||
func downloadFFmpegArchive(ctx context.Context, log *zap.Logger, urls []string, dest string) error {
|
||||
var lastErr error
|
||||
for i, u := range urls {
|
||||
if i > 0 && log != nil {
|
||||
log.Warn("ffmpeg 主下载源不可用,切换备用源", zap.String("url", u))
|
||||
}
|
||||
if err := downloadFFmpegFile(ctx, log, u, dest); err != nil {
|
||||
lastErr = err
|
||||
if log != nil {
|
||||
log.Warn("ffmpeg 下载失败", zap.String("url", u), zap.Error(err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("所有下载源均失败:%v", lastErr)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
// downloadFFmpegFile 下载单个归档文件(最多 10 分钟,限制大小上限)。
|
||||
func downloadFFmpegFile(ctx context.Context, log *zap.Logger, url, dest string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "MMTL/ffmpeg-installer ("+runtime.GOOS+"/"+runtime.GOARCH+")")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("下载失败,HTTP 状态码: %d", resp.StatusCode)
|
||||
return fmt.Errorf("下载失败,HTTP 状态码 %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
out, err := os.Create(filepath) // #nosec G304 -- filepath is generated by the installer under its temporary download directory.
|
||||
out, err := os.Create(dest) // #nosec G304 -- dest 是安装器在临时目录下生成的文件。
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
return err
|
||||
n, err := io.Copy(out, io.LimitReader(resp.Body, 500<<20+1)) // 归档上限 500MB
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 500<<20 {
|
||||
return fmt.Errorf("归档文件过大(>500MB): %s", url)
|
||||
}
|
||||
if log != nil {
|
||||
log.Info("ffmpeg 归档下载完成", zap.String("url", url), zap.Int64("bytes", n))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateConfigPaths 更新配置文件中的路径
|
||||
func updateConfigPaths(cfg *config.Config, ffprobePath, ffmpegPath string) {
|
||||
cfg.App.FFprobePath = ffprobePath
|
||||
cfg.App.FFmpegPath = ffmpegPath
|
||||
|
||||
// 保存到配置文件
|
||||
// 这里需要调用 config 包的保存函数
|
||||
log := zap.L().Named("config")
|
||||
log.Info("已更新 ffmpeg 路径配置",
|
||||
zap.String("ffprobe", ffprobePath),
|
||||
zap.String("ffmpeg", ffmpegPath))
|
||||
// extractFFmpegArchive 按类型解压 zip 或 tar.xz。
|
||||
func extractFFmpegArchive(kind, archivePath, destDir string) error {
|
||||
switch kind {
|
||||
case "zip":
|
||||
return unzip(nil, archivePath, destDir)
|
||||
case "tar.xz":
|
||||
return untarXZ(archivePath, destDir)
|
||||
default:
|
||||
return fmt.Errorf("不支持的归档类型: %s", kind)
|
||||
}
|
||||
}
|
||||
|
||||
// is64Bit 检查是否为 64 位系统
|
||||
func is64Bit() bool {
|
||||
return true // 简化处理,假设为 64 位
|
||||
// untarXZ 解压 .tar.xz 归档(GNU tar + xz 流式解压,纯 Go 无外部依赖),
|
||||
// 路径安全校验与 ZIP 解压一致。
|
||||
func untarXZ(archivePath, destDir string) error {
|
||||
if err := os.MkdirAll(destDir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
destRoot, err := filepath.Abs(destDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Open(archivePath) // #nosec G304 -- archivePath 是安装器在临时目录下生成的文件。
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
xzReader, err := xz.NewReader(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tr := tar.NewReader(xzReader)
|
||||
var totalWritten int64
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := safeZipTarget(destRoot, hdr.Name) // 与 ZIP 相同的路径穿越防护
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg, tar.TypeRegA:
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
dst, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.FileMode(hdr.Mode).Perm())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
written, copyErr := io.Copy(dst, io.LimitReader(tr, maxFFmpegZipEntryBytes+1))
|
||||
totalWritten += written
|
||||
closeErr := dst.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
if written > maxFFmpegZipEntryBytes || totalWritten > maxFFmpegZipTotalBytes {
|
||||
return fmt.Errorf("tar 内容过大: %s", hdr.Name)
|
||||
}
|
||||
default:
|
||||
// 符号链接/设备等一律跳过(静态构建不会依赖它们)。
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// locateFFmpegBinaries 在解压目录中查找 ffmpeg/ffprobe 可执行文件(兼容
|
||||
// 不同构建包的目录布局:gyan 的 bin/、BtbN/johnvansickle 的根目录等)。
|
||||
func locateFFmpegBinaries(root, exeSuffix string) (ffmpeg, ffprobe string, err error) {
|
||||
wantFFmpeg := "ffmpeg" + strings.ToLower(exeSuffix)
|
||||
wantFFprobe := "ffprobe" + strings.ToLower(exeSuffix)
|
||||
err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
switch strings.ToLower(d.Name()) {
|
||||
case wantFFmpeg:
|
||||
if ffmpeg == "" {
|
||||
ffmpeg = path
|
||||
}
|
||||
case wantFFprobe:
|
||||
if ffprobe == "" {
|
||||
ffprobe = path
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("扫描解压目录失败: %w", err)
|
||||
}
|
||||
if ffmpeg == "" || ffprobe == "" {
|
||||
return "", "", fmt.Errorf("解压内容中未找到 ffmpeg/ffprobe 可执行文件")
|
||||
}
|
||||
return ffmpeg, ffprobe, nil
|
||||
}
|
||||
|
||||
// copyFileMode 复制文件并赋予可执行权限。
|
||||
func copyFileMode(src, dst string) error {
|
||||
in, err := os.Open(src) // #nosec G304 -- src 来自解压目录遍历结果。
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
_ = out.Close()
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
@@ -41,7 +41,9 @@ func unzip(log *zap.Logger, zipPath, destDir string) error {
|
||||
}
|
||||
info := file.FileInfo()
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
log.Warn("跳过 ZIP 符号链接", zap.String("name", file.Name))
|
||||
if log != nil {
|
||||
log.Warn("跳过 ZIP 符号链接", zap.String("name", file.Name))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
@@ -110,93 +112,3 @@ func safeZipTarget(destRoot, name string) (string, error) {
|
||||
return targetAbs, nil
|
||||
}
|
||||
|
||||
func findFFmpegPackageRoot(root string) (string, error) {
|
||||
var ffmpegPath string
|
||||
var ffprobePath string
|
||||
|
||||
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch strings.ToLower(d.Name()) {
|
||||
case "ffmpeg.exe":
|
||||
ffmpegPath = path
|
||||
case "ffprobe.exe":
|
||||
ffprobePath = path
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("扫描解压目录失败: %w", err)
|
||||
}
|
||||
if ffmpegPath == "" || ffprobePath == "" {
|
||||
return "", fmt.Errorf("解压后未找到 ffmpeg/ffprobe 可执行文件")
|
||||
}
|
||||
|
||||
return filepath.Dir(filepath.Dir(ffmpegPath)), nil
|
||||
}
|
||||
|
||||
func copyDirContents(srcDir, dstDir string) error {
|
||||
entries, err := os.ReadDir(srcDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
srcPath := filepath.Join(srcDir, entry.Name())
|
||||
dstPath := filepath.Join(dstDir, entry.Name())
|
||||
if err := copyTree(srcPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyTree(srcPath, dstPath string) error {
|
||||
info, err := os.Stat(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
if err := os.MkdirAll(dstPath, info.Mode()); err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := os.ReadDir(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if err := copyTree(filepath.Join(srcPath, entry.Name()), filepath.Join(dstPath, entry.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
in, err := os.Open(srcPath) // #nosec G304 -- srcPath is produced by walking the validated extracted ffmpeg package tree.
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dstPath), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := os.Create(dstPath) // #nosec G304 -- dstPath is generated under the configured ffmpeg install directory.
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// CheckFFmpegStatus 检查 ffmpeg/ffprobe 状态 (供 API 使用)
|
||||
func CheckFFmpegStatus(ffprobePath, ffmpegPath string) map[string]interface{} {
|
||||
status := map[string]interface{}{
|
||||
"ffprobe_installed": false,
|
||||
"ffmpeg_installed": false,
|
||||
"auto_installable": runtime.GOOS == "windows",
|
||||
}
|
||||
|
||||
if ffprobePath != "" {
|
||||
if _, err := os.Stat(ffprobePath); err == nil {
|
||||
status["ffprobe_installed"] = true
|
||||
status["ffprobe_path"] = ffprobePath
|
||||
|
||||
// 获取版本
|
||||
cmd := exec.Command(ffprobePath, "-version")
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
// 提取版本信息(第一行)
|
||||
lines := bytes.Split(out, []byte("\n"))
|
||||
if len(lines) > 0 {
|
||||
version := string(bytes.TrimSpace(lines[0]))
|
||||
status["ffprobe_version"] = version
|
||||
status["ffprobe_security"] = EvaluateFFmpegSecurity(version)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ffmpegPath != "" {
|
||||
if _, err := os.Stat(ffmpegPath); err == nil {
|
||||
status["ffmpeg_installed"] = true
|
||||
status["ffmpeg_path"] = ffmpegPath
|
||||
|
||||
cmd := exec.Command(ffmpegPath, "-version")
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
lines := bytes.Split(out, []byte("\n"))
|
||||
if len(lines) > 0 {
|
||||
version := string(bytes.TrimSpace(lines[0]))
|
||||
status["ffmpeg_version"] = version
|
||||
status["ffmpeg_security"] = EvaluateFFmpegSecurity(version)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// Package service — ffmpeg/ffprobe 自动下载安装。
|
||||
//
|
||||
// FFmpegToolsService 负责「一键下载」:点击后按当前运行平台(OS+架构)选择
|
||||
// 官方构建包(Windows: gyan.dev / BtbN;Linux: BtbN / johnvansickle;
|
||||
// macOS: BtbN),下载解压 ffmpeg/ffprobe 到 data 目录(data/tools/ffmpeg/),
|
||||
// 并把绝对路径写入设置(ffmpeg.path / ffprobe.path),系统随即使用安装的
|
||||
// 工具,无需手动填写路径。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
)
|
||||
|
||||
// FFmpegToolsService 管理 ffmpeg/ffprobe 的自动下载安装状态(单飞:同一时间
|
||||
// 只允许一个安装任务)。
|
||||
type FFmpegToolsService struct {
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
msg string // 最近阶段/结果消息
|
||||
errMsg string // 最近一次失败原因
|
||||
started time.Time // 最近一次安装开始时间
|
||||
done time.Time // 最近一次安装结束时间
|
||||
}
|
||||
|
||||
// NewFFmpegToolsService 构造工具安装服务。
|
||||
func NewFFmpegToolsService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *FFmpegToolsService {
|
||||
return &FFmpegToolsService{cfg: cfg, log: log, repo: repo}
|
||||
}
|
||||
|
||||
// ffmpegInstallDir 返回 data 目录下的安装位置。
|
||||
func (s *FFmpegToolsService) ffmpegInstallDir() string {
|
||||
return filepath.Join(s.cfg.App.DataDir, "tools", "ffmpeg")
|
||||
}
|
||||
|
||||
// installedBinaries 检查安装目录中是否已存在 ffmpeg/ffprobe 可执行文件。
|
||||
func (s *FFmpegToolsService) installedBinaries() (ffmpeg, ffprobe string) {
|
||||
exe := ""
|
||||
if runtime.GOOS == "windows" {
|
||||
exe = ".exe"
|
||||
}
|
||||
ffmpeg = filepath.Join(s.ffmpegInstallDir(), "ffmpeg"+exe)
|
||||
ffprobe = filepath.Join(s.ffmpegInstallDir(), "ffprobe"+exe)
|
||||
if _, err := os.Stat(ffmpeg); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
if _, err := os.Stat(ffprobe); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
return ffmpeg, ffprobe
|
||||
}
|
||||
|
||||
// ffToolVersion 取工具第一行版本信息。
|
||||
func ffToolVersion(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
out, err := exec.Command(path, "-version").Output() // #nosec G204 -- path 来自配置/安装目录中的已知工具。
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
line := strings.SplitN(strings.TrimSpace(string(out)), "\n", 2)
|
||||
if len(line) == 0 || strings.TrimSpace(line[0]) == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(line[0])
|
||||
}
|
||||
|
||||
// ffToolInfo 是单个工具的安装状态(返回给前端展示)。
|
||||
type ffToolInfo struct {
|
||||
Installed bool `json:"installed"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
// Status 返回当前安装状态(供 GET /api/admin/tools/ffmpeg/status 使用)。
|
||||
func (s *FFmpegToolsService) Status(ctx context.Context) map[string]any {
|
||||
s.mu.Lock()
|
||||
running, msg, errMsg, started, done := s.running, s.msg, s.errMsg, s.started, s.done
|
||||
s.mu.Unlock()
|
||||
|
||||
startedAt, doneAt := "", ""
|
||||
if !started.IsZero() {
|
||||
startedAt = started.Format(time.RFC3339)
|
||||
}
|
||||
if !done.IsZero() {
|
||||
doneAt = done.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
out := map[string]any{
|
||||
"installing": running,
|
||||
"message": msg,
|
||||
"error": errMsg,
|
||||
"started_at": startedAt,
|
||||
"finished_at": doneAt,
|
||||
"install_dir": s.ffmpegInstallDir(),
|
||||
}
|
||||
target, targetErr := ffmpegTargetForPlatform()
|
||||
if targetErr != nil {
|
||||
out["target"] = map[string]any{"label": targetErr.Error()}
|
||||
} else {
|
||||
out["target"] = map[string]any{
|
||||
"os": runtime.GOOS,
|
||||
"arch": runtime.GOARCH,
|
||||
"label": target.Label,
|
||||
}
|
||||
}
|
||||
// 报告「系统当前实际会使用」的工具:优先已生效配置(安装完成会把设置指到
|
||||
// data 目录),其次 PATH / 常见目录。
|
||||
ffmpegPath, ferr := resolveLocalExecutable(s.cfg.App.FFmpegPath, "ffmpeg")
|
||||
ffprobePath, perr := resolveLocalExecutable(s.cfg.App.FFprobePath, "ffprobe")
|
||||
out["ffmpeg"] = ffToolInfo{Installed: ferr == nil, Path: ffmpegPath, Version: ffToolVersion(ffmpegPath)}
|
||||
out["ffprobe"] = ffToolInfo{Installed: perr == nil, Path: ffprobePath, Version: ffToolVersion(ffprobePath)}
|
||||
return out
|
||||
}
|
||||
|
||||
// StartInstall 启动后台安装(幂等)。正在安装时返回错误;data 目录已有完整
|
||||
// 工具时直接应用路径设置并返回(无需重新下载)。
|
||||
func (s *FFmpegToolsService) StartInstall(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
if s.running {
|
||||
s.mu.Unlock()
|
||||
return errors.New("工具正在安装中,请稍候")
|
||||
}
|
||||
if ffmpeg, ffprobe := s.installedBinaries(); ffmpeg != "" && ffprobe != "" {
|
||||
s.mu.Unlock()
|
||||
s.setMessage("检测到已安装,直接应用配置")
|
||||
if err := s.applyInstalledPaths(ctx, ffmpeg, ffprobe); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
s.running = true
|
||||
s.errMsg = ""
|
||||
s.started = time.Now()
|
||||
s.mu.Unlock()
|
||||
|
||||
s.setMessage("准备下载…")
|
||||
go s.runInstall()
|
||||
return nil
|
||||
}
|
||||
|
||||
// runInstall 在后台执行下载、解压、验证与配置落盘。
|
||||
func (s *FFmpegToolsService) runInstall() {
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
s.running = false
|
||||
s.done = time.Now()
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
ffmpegPath, ffprobePath, err := installFFmpegTools(ctx, s.log, s.cfg, s.setMessage)
|
||||
if err != nil {
|
||||
s.mu.Lock()
|
||||
s.errMsg = err.Error()
|
||||
s.msg = "安装失败"
|
||||
s.mu.Unlock()
|
||||
s.log.Error("ffmpeg 工具安装失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if err := s.applyInstalledPaths(ctx, ffmpegPath, ffprobePath); err != nil {
|
||||
s.mu.Lock()
|
||||
s.errMsg = "安装完成,但写入设置失败:" + err.Error()
|
||||
s.msg = "安装完成,设置写入失败"
|
||||
s.mu.Unlock()
|
||||
s.log.Error("写入 ffmpeg 工具路径设置失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
s.setMessage("安装完成")
|
||||
s.log.Info("ffmpeg 工具安装完成",
|
||||
zap.String("ffmpeg", ffmpegPath), zap.String("ffprobe", ffprobePath))
|
||||
}
|
||||
|
||||
// applyInstalledPaths 把安装后的路径写入设置表并热应用到运行配置。
|
||||
func (s *FFmpegToolsService) applyInstalledPaths(ctx context.Context, ffmpeg, ffprobe string) error {
|
||||
if s.repo != nil && s.repo.Setting != nil {
|
||||
if err := s.repo.Setting.Set(ctx, "ffmpeg.path", ffmpeg); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.Setting.Set(ctx, "ffprobe.path", ffprobe); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
ApplyRuntimeSetting(s.cfg, "ffmpeg.path", ffmpeg)
|
||||
ApplyRuntimeSetting(s.cfg, "ffprobe.path", ffprobe)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *FFmpegToolsService) setMessage(msg string) {
|
||||
s.mu.Lock()
|
||||
s.msg = msg
|
||||
s.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
)
|
||||
|
||||
func TestFFmpegTargetForPlatform(t *testing.T) {
|
||||
target, err := ffmpegTargetForPlatform()
|
||||
switch runtime.GOOS {
|
||||
case "windows", "linux", "darwin":
|
||||
if err != nil {
|
||||
t.Fatalf("supported platform %s/%s should resolve a target: %v", runtime.GOOS, runtime.GOARCH, err)
|
||||
}
|
||||
if target == nil || target.Label == "" || len(target.Archives) == 0 {
|
||||
t.Fatalf("target incomplete: %#v", target)
|
||||
}
|
||||
if target.Kind != "zip" && target.Kind != "tar.xz" {
|
||||
t.Fatalf("unexpected archive kind: %s", target.Kind)
|
||||
}
|
||||
for _, u := range target.Archives {
|
||||
if !strings.HasPrefix(u, "https://") {
|
||||
t.Fatalf("archive url not https: %s", u)
|
||||
}
|
||||
}
|
||||
default:
|
||||
if err == nil {
|
||||
t.Fatalf("unsupported platform %s/%s should fail", runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeZipTargetRejectsTraversal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"../evil", "..\\evil", "/etc/passwd", "a/../../evil"} {
|
||||
if _, err := safeZipTarget(root, name); err == nil {
|
||||
t.Fatalf("expected traversal rejection for %q", name)
|
||||
}
|
||||
}
|
||||
if _, err := safeZipTarget(root, "bin/ffmpeg.exe"); err != nil {
|
||||
t.Fatalf("valid relative path should pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFFmpegToolsStatusNoPanic(t *testing.T) {
|
||||
svc := NewFFmpegToolsService(&config.Config{}, zap.NewNop(), nil)
|
||||
st := svc.Status(context.Background())
|
||||
for _, key := range []string{"installing", "message", "error", "install_dir", "target", "ffmpeg", "ffprobe"} {
|
||||
if _, ok := st[key]; !ok {
|
||||
t.Fatalf("status missing key %q: %#v", key, st)
|
||||
}
|
||||
}
|
||||
ffmpeg, ok := st["ffmpeg"].(ffToolInfo)
|
||||
if !ok {
|
||||
t.Fatalf("ffmpeg field not ffToolInfo: %T", st["ffmpeg"])
|
||||
}
|
||||
if ffmpeg.Installed {
|
||||
t.Fatalf("empty config should not report installed ffmpeg: %#v", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartInstallRejectsConcurrent(t *testing.T) {
|
||||
svc := NewFFmpegToolsService(&config.Config{App: config.AppConfig{DataDir: t.TempDir()}}, zap.NewNop(), nil)
|
||||
// 不真实运行:直接占用 running 标记模拟进行中的安装。
|
||||
svc.mu.Lock()
|
||||
svc.running = true
|
||||
svc.mu.Unlock()
|
||||
if err := svc.StartInstall(context.Background()); err == nil {
|
||||
t.Fatalf("second install while running should be rejected")
|
||||
}
|
||||
svc.mu.Lock()
|
||||
svc.running = false
|
||||
svc.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -4,14 +4,88 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type LibraryPreviewItem struct {
|
||||
model.Library
|
||||
Total int64 `json:"total"`
|
||||
Cards []SeriesCard `json:"cards"`
|
||||
}
|
||||
|
||||
// ListLibraries returns every library configured on the server.
|
||||
func (s *MediaService) ListLibraries(ctx context.Context) ([]model.Library, error) {
|
||||
return s.repo.Library.List(ctx)
|
||||
}
|
||||
|
||||
// ListLibrariesWithPreview returns libraries populated with item counts and latest preview cards.
|
||||
func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries []model.Library, visibility MediaVisibility, cardLimit int) ([]LibraryPreviewItem, error) {
|
||||
if cardLimit <= 0 {
|
||||
cardLimit = 10
|
||||
}
|
||||
out := make([]LibraryPreviewItem, len(libraries))
|
||||
if len(libraries) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
libIDs := make([]string, 0, len(libraries))
|
||||
for i, lib := range libraries {
|
||||
out[i] = LibraryPreviewItem{
|
||||
Library: lib,
|
||||
Total: 0,
|
||||
Cards: []SeriesCard{},
|
||||
}
|
||||
libIDs = append(libIDs, lib.ID)
|
||||
}
|
||||
|
||||
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
|
||||
filter := repository.MediaQueryFilter{
|
||||
IncludeNSFW: visibility.IncludeNSFW,
|
||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||
}
|
||||
|
||||
counts, err := s.repo.Media.CountByLibraries(ctx, libIDs, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range out {
|
||||
if total, ok := counts[out[i].ID]; ok {
|
||||
out[i].Total = total
|
||||
}
|
||||
}
|
||||
|
||||
fetchCount := cardLimit * 4
|
||||
if fetchCount < 60 {
|
||||
fetchCount = 60
|
||||
} else if fetchCount > 200 {
|
||||
fetchCount = 200
|
||||
}
|
||||
|
||||
for i := range out {
|
||||
if out[i].Total == 0 {
|
||||
continue
|
||||
}
|
||||
items, _, err := s.repo.Media.ListByLibrariesFiltered(ctx, []string{out[i].ID}, 0, fetchCount, filter)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s.attachLibraryMetadata(ctx, items)
|
||||
cards := groupMediaSeriesCards(items)
|
||||
if len(cards) > cardLimit {
|
||||
cards = cards[:cardLimit]
|
||||
}
|
||||
if cards == nil {
|
||||
cards = []SeriesCard{}
|
||||
}
|
||||
out[i].Cards = cards
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteLibrary removes a library and its media rows. The on-disk files are
|
||||
// left untouched.
|
||||
func (s *MediaService) DeleteLibrary(ctx context.Context, id string) error {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
)
|
||||
|
||||
func TestListLibrariesWithPreview(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||||
repos := repository.New(db)
|
||||
|
||||
lib1 := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lib2 := model.Library{Name: "动漫", Path: "/media/anime", Type: "anime", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC)
|
||||
var rows []model.Media
|
||||
|
||||
// Add 5 movies to lib1
|
||||
for i := 1; i <= 5; i++ {
|
||||
rows = append(rows, model.Media{
|
||||
Base: model.Base{ID: fmt.Sprintf("movie-%02d", i), CreatedAt: now.Add(time.Duration(i) * time.Hour)},
|
||||
LibraryID: lib1.ID,
|
||||
Title: fmt.Sprintf("电影%d", i),
|
||||
Path: fmt.Sprintf("/media/movies/电影%d/movie%d.mp4", i, i),
|
||||
PosterURL: fmt.Sprintf("/api/media/movie-%02d/poster", i),
|
||||
})
|
||||
}
|
||||
|
||||
// Add 12 episodes of 1 anime to lib2
|
||||
for i := 1; i <= 12; i++ {
|
||||
rows = append(rows, model.Media{
|
||||
Base: model.Base{ID: fmt.Sprintf("anime-ep-%02d", i), CreatedAt: now.Add(time.Duration(i) * time.Minute)},
|
||||
LibraryID: lib2.ID,
|
||||
Title: fmt.Sprintf("某动漫 第%d集", i),
|
||||
Path: fmt.Sprintf("/media/anime/某动漫/Season 01/某动漫.S01E%02d.mp4", i),
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: i,
|
||||
PosterURL: "/api/media/anime-01/poster",
|
||||
})
|
||||
}
|
||||
|
||||
if err := repos.DB.Create(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
||||
|
||||
previews, err := svc.ListLibrariesWithPreview(t.Context(), []model.Library{lib1, lib2}, MediaVisibility{IncludeNSFW: true}, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListLibrariesWithPreview failed: %v", err)
|
||||
}
|
||||
|
||||
if len(previews) != 2 {
|
||||
t.Fatalf("got %d previews, want 2", len(previews))
|
||||
}
|
||||
|
||||
// Verify lib1 (movies)
|
||||
if previews[0].ID != lib1.ID {
|
||||
t.Errorf("preview[0].ID = %q, want %q", previews[0].ID, lib1.ID)
|
||||
}
|
||||
if previews[0].Total != 5 {
|
||||
t.Errorf("preview[0].Total = %d, want 5", previews[0].Total)
|
||||
}
|
||||
if len(previews[0].Cards) != 5 {
|
||||
t.Errorf("preview[0].Cards count = %d, want 5", len(previews[0].Cards))
|
||||
}
|
||||
|
||||
// Verify lib2 (anime)
|
||||
if previews[1].ID != lib2.ID {
|
||||
t.Errorf("preview[1].ID = %q, want %q", previews[1].ID, lib2.ID)
|
||||
}
|
||||
if previews[1].Total != 12 {
|
||||
t.Errorf("preview[1].Total = %d, want 12", previews[1].Total)
|
||||
}
|
||||
// 12 episodes should be grouped into 1 SeriesCard with Count = 12
|
||||
if len(previews[1].Cards) != 1 {
|
||||
t.Errorf("preview[1].Cards count = %d, want 1", len(previews[1].Cards))
|
||||
} else if previews[1].Cards[0].Count != 12 {
|
||||
t.Errorf("preview[1].Cards[0].Count = %d, want 12", previews[1].Cards[0].Count)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -75,6 +77,74 @@ func (s *MediaService) ListLibrarySeriesEpisodes(ctx context.Context, libraryID,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, visibility MediaVisibility) ([]model.Media, error) {
|
||||
target, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if target == nil {
|
||||
return nil, errors.New("media not found")
|
||||
}
|
||||
if !visibility.Allows(target) {
|
||||
return nil, errors.New("media not found")
|
||||
}
|
||||
if target.LibraryID == "" {
|
||||
return []model.Media{*target}, nil
|
||||
}
|
||||
rows, _, err := s.listAllMediaVisible(ctx, target.LibraryID, visibility)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return []model.Media{*target}, nil
|
||||
}
|
||||
|
||||
resolver := newMediaSeriesKeyResolver(rows)
|
||||
targetKey := resolver.key(*target)
|
||||
|
||||
out := make([]model.Media, 0)
|
||||
if targetKey != "" {
|
||||
for _, row := range rows {
|
||||
if resolver.key(row) == targetKey {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有聚合到多集,尝试同父目录匹配
|
||||
if len(out) <= 1 && target.Path != "" {
|
||||
targetDir := filepath.Dir(strings.ReplaceAll(target.Path, "\\", "/"))
|
||||
dirMatches := make([]model.Media, 0)
|
||||
for _, row := range rows {
|
||||
if row.Path != "" && filepath.Dir(strings.ReplaceAll(row.Path, "\\", "/")) == targetDir {
|
||||
dirMatches = append(dirMatches, row)
|
||||
}
|
||||
}
|
||||
if len(dirMatches) > 1 {
|
||||
out = dirMatches
|
||||
}
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
out = []model.Media{*target}
|
||||
}
|
||||
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].SeasonNum != out[j].SeasonNum {
|
||||
return out[i].SeasonNum < out[j].SeasonNum
|
||||
}
|
||||
if out[i].EpisodeNum != out[j].EpisodeNum {
|
||||
return out[i].EpisodeNum < out[j].EpisodeNum
|
||||
}
|
||||
if out[i].Path != out[j].Path {
|
||||
return out[i].Path < out[j].Path
|
||||
}
|
||||
return out[i].CreatedAt.Before(out[j].CreatedAt)
|
||||
})
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *MediaService) listAllMediaVisible(ctx context.Context, libraryID string, visibility MediaVisibility) ([]model.Media, int64, error) {
|
||||
const pageSize = 2000
|
||||
var all []model.Media
|
||||
|
||||
@@ -58,11 +58,13 @@ type Container struct {
|
||||
Device *DeviceService
|
||||
Cache *RuntimeCacheService
|
||||
Sessions *SessionTrackerService
|
||||
RecognitionWords *RecognitionWordsService
|
||||
Danmaku *DanmakuService
|
||||
Strm *StrmService
|
||||
RecognitionWords *RecognitionWordsService
|
||||
Danmaku *DanmakuService
|
||||
Strm *StrmService
|
||||
Database *DatabaseAdminService
|
||||
FFTools *FFmpegToolsService
|
||||
|
||||
stopCtx context.Context
|
||||
stopCtx context.Context
|
||||
stopCancel context.CancelFunc
|
||||
|
||||
// ReloadHTTPServer 由 cmd/server 注入。HTTPS 相关设置保存后,handler
|
||||
|
||||
@@ -111,6 +111,8 @@ func (b *serviceContainerBuilder) initContentServices() {
|
||||
b.c.Profile = NewProfileService(b.log, b.repos)
|
||||
b.c.Audit = NewAuditService(b.log, b.repos)
|
||||
b.c.Strm = NewStrmService(b.cfg, b.log, b.repos, b.c.Crypto)
|
||||
// ffmpeg/ffprobe 一键下载安装(data/tools/ffmpeg/)。
|
||||
b.c.FFTools = NewFFmpegToolsService(b.cfg, b.log, b.repos)
|
||||
// 弹幕 hash 识别需要把 strm 指向解析成可拉取的直链/本地路径。
|
||||
b.c.Danmaku.SetStrmResolver(b.c.Strm.ResolvePlay)
|
||||
}
|
||||
@@ -118,6 +120,7 @@ func (b *serviceContainerBuilder) initContentServices() {
|
||||
func (b *serviceContainerBuilder) initAccessAndStorageServices() {
|
||||
b.c.PlayProfiles = NewPlayProfileService(b.log, b.repos)
|
||||
b.c.Permissions = NewPermissionService(b.log, b.repos)
|
||||
b.c.Database = NewDatabaseAdminService(b.cfg, b.log, b.repos, b.repos.DB)
|
||||
b.c.Emby.SetRuntimeCache(b.c.Cache)
|
||||
b.c.Emby.SetSubtitleService(b.c.Subtitle)
|
||||
b.c.Scheduler = NewSchedulerService(
|
||||
|
||||
@@ -116,6 +116,64 @@ func (s *StrmService) resolveLocalPlay(ctx context.Context, rawPath string) (*St
|
||||
return nil, errors.New("文件不在任何本地同步目录内")
|
||||
}
|
||||
|
||||
// ResolvePlayTarget 解析媒体行固化的播放目标(STRMURL 或 .strm 文件内容)为
|
||||
// 可播放结果,供弹幕 hash、内嵌字幕提取等「先解析直链再读取远端」的场景复用。
|
||||
// 支持:
|
||||
// - /api/strm/play/{provider}/video{ext}?acct=..&pickcode=.. (常规格式,含账号)
|
||||
// - /api/cloud/play/{type}?ref=.. (旧格式,无账号 → 取该类型第一个启用账号)
|
||||
// - 绝对 http(s) 链接(直接透传)
|
||||
// - 其余协议(webdav:// 等)返回错误,由调用方决定是否静默跳过
|
||||
func (s *StrmService) ResolvePlayTarget(ctx context.Context, raw string) (*StrmPlayResult, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, errors.New("空播放目标")
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析播放目标失败: %w", err)
|
||||
}
|
||||
lowerPath := strings.ToLower(u.Path)
|
||||
switch {
|
||||
case strings.HasPrefix(lowerPath, "/api/strm/play/"):
|
||||
segs := strings.Split(strings.TrimPrefix(u.Path, "/api/strm/play/"), "/")
|
||||
if len(segs) < 1 || strings.TrimSpace(segs[0]) == "" {
|
||||
return nil, errors.New("无效的 strm 播放地址")
|
||||
}
|
||||
return s.ResolvePlay(ctx, segs[0], u.Query())
|
||||
case strings.HasPrefix(lowerPath, "/api/cloud/play/"):
|
||||
typ := strings.TrimSpace(strings.TrimPrefix(u.Path, "/api/cloud/play/"))
|
||||
acct, err := s.firstEnabledAccountOf(ctx, typ)
|
||||
if err != nil || acct == nil {
|
||||
return nil, errors.New("没有可用的网盘账号,无法解析直链")
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("acct", acct.ID)
|
||||
return s.ResolvePlay(ctx, typ, q)
|
||||
case u.Scheme == "http" || u.Scheme == "https":
|
||||
return &StrmPlayResult{RedirectURL: raw}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的播放目标协议: %s", u.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
// firstEnabledAccountOf 返回指定提供方第一个凭据可用的启用账号。
|
||||
func (s *StrmService) firstEnabledAccountOf(ctx context.Context, provider string) (*model.StrmAccount, error) {
|
||||
accounts, err := s.repo.StrmAccount.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range accounts {
|
||||
a := &accounts[i]
|
||||
if !a.Enabled || a.Provider != provider {
|
||||
continue
|
||||
}
|
||||
if _, err := s.providerFor(ctx, a); err == nil {
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ProxyDirect 反向代理渲染直链内容(保留 Range 请求头以支持拖动播放)。
|
||||
func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, link *cloud.DirectLink) error {
|
||||
if link == nil || link.URL == "" {
|
||||
|
||||
+16
-179
@@ -1,8 +1,8 @@
|
||||
// Package service — subtitle handling.
|
||||
//
|
||||
// SubtitleService finds external subtitle files next to a media file AND
|
||||
// embedded text subtitle tracks inside the media container, exposing both as
|
||||
// WebVTT so the browser <track> element can load them directly.
|
||||
// SubtitleService finds external subtitle files next to a media file and
|
||||
// exposes them as WebVTT so the browser <track> element can load them
|
||||
// directly, or as the original bytes for Emby/Jellyfin clients.
|
||||
//
|
||||
// External-subtitle discovery rules (matching the legacy Python defaults):
|
||||
//
|
||||
@@ -12,25 +12,17 @@
|
||||
// ?lang=zh / ?lang=en.
|
||||
//
|
||||
// Supported extensions: .srt, .ass, .ssa, .vtt.
|
||||
//
|
||||
// Embedded subtitles are probed with ffprobe and exposed as
|
||||
// path "embedded:<stream-index>"; the browser endpoint extracts the stream
|
||||
// via ffmpeg into a cached .vtt file.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -71,6 +63,16 @@ var extToCodec = map[string]string{
|
||||
// relative; the caller should prepend /api/subtitles/<media_id>?path=...
|
||||
// when serializing for the frontend.
|
||||
func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
||||
return s.discover(ctx, mediaID)
|
||||
}
|
||||
|
||||
// DiscoverExternalOnly 只返回媒体旁边的外挂字幕文件,不含容器内嵌字幕轨。
|
||||
// Emby 字幕接口(/Videos/:id/Subtitles/...)用。
|
||||
func (s *SubtitleService) DiscoverExternalOnly(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
||||
return s.discover(ctx, mediaID)
|
||||
}
|
||||
|
||||
func (s *SubtitleService) discover(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
||||
m, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -118,94 +120,9 @@ func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]Subti
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 容器内嵌文本字幕轨(MKV/MP4 等封装内的字幕流):本地真实文件才可
|
||||
// 探测提取;cloud:// 与 .strm 媒体跳过。探测失败静默忽略(无 ffprobe
|
||||
// 或没有字幕流都属正常)。
|
||||
if embedded, ok := s.discoverEmbeddedTracks(ctx, m.Path); ok {
|
||||
tracks = append(tracks, embedded...)
|
||||
}
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
// embeddedCodecOK 只暴露可提取为 WebVTT 的文本字幕编解码器;位图字幕
|
||||
// (PGS/DVDSUB/DVBSUB)浏览器无法渲染,跳过。
|
||||
func embeddedCodecOK(codec string) bool {
|
||||
switch strings.ToLower(codec) {
|
||||
case "subrip", "srt", "mov_text", "text", "webvtt", "ass", "ssa", "ttml", "sami":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ffprobeSubtitleStream 是 ffprobe -show_streams 输出的字幕流字段。
|
||||
type ffprobeSubtitleStream struct {
|
||||
Index int `json:"index"`
|
||||
Codec string `json:"codec_name"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
}
|
||||
|
||||
type ffprobeSubtitleContainer struct {
|
||||
Streams []ffprobeSubtitleStream `json:"streams"`
|
||||
}
|
||||
|
||||
// discoverEmbeddedTracks 用 ffprobe 探测媒体容器内的文本字幕轨。
|
||||
// 返回 (tracks, ok):ok=false 表示该媒体不适用(非本地文件/ffprobe 不可用)。
|
||||
func (s *SubtitleService) discoverEmbeddedTracks(ctx context.Context, mediaPath string) ([]SubtitleTrack, bool) {
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(mediaPath)), "cloud://") ||
|
||||
strings.HasSuffix(strings.ToLower(strings.TrimSpace(mediaPath)), ".strm") {
|
||||
return nil, false
|
||||
}
|
||||
bin, err := resolveLocalExecutable(s.cfg.App.FFprobePath, "ffprobe")
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if _, err := os.Stat(mediaPath); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(probeCtx, bin, // #nosec G204 -- bin resolved by resolveLocalExecutable; args are fixed probes.
|
||||
"-v", "error",
|
||||
"-select_streams", "s",
|
||||
"-show_entries", "stream=index,codec_name:stream_tags=language,title",
|
||||
"-of", "json",
|
||||
"--", mediaPath,
|
||||
)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
var container ffprobeSubtitleContainer
|
||||
if err := json.Unmarshal(out, &container); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
tracks := make([]SubtitleTrack, 0, len(container.Streams))
|
||||
for _, stream := range container.Streams {
|
||||
if !embeddedCodecOK(stream.Codec) {
|
||||
continue
|
||||
}
|
||||
lang := strings.ToLower(strings.TrimSpace(stream.Tags["language"]))
|
||||
if lang == "" {
|
||||
lang = "und"
|
||||
}
|
||||
label := stream.Tags["title"]
|
||||
if label == "" {
|
||||
label = lang
|
||||
}
|
||||
tracks = append(tracks, SubtitleTrack{
|
||||
Lang: lang,
|
||||
Label: "内置字幕 · " + label,
|
||||
Path: "embedded:" + strconv.Itoa(stream.Index),
|
||||
Codec: stream.Codec,
|
||||
})
|
||||
}
|
||||
return tracks, true
|
||||
}
|
||||
|
||||
// langTag matches the .zh / .zh-cn / .chs language sub-extensions.
|
||||
var langTag = regexp.MustCompile(`(?i)\.([a-z]{2,3}(?:[-_][a-z]{2,4})?)$`)
|
||||
|
||||
@@ -222,18 +139,13 @@ func detectLang(name, base string) string {
|
||||
}
|
||||
|
||||
// Serve writes the subtitle file as WebVTT (.vtt). SRT/SSA files are
|
||||
// converted minimally on the fly; embedded container tracks (path
|
||||
// "embedded:<index>") are extracted via ffmpeg into a cached .vtt.
|
||||
// Returns ErrSubtitleNotFound when the path is rejected (path traversal /
|
||||
// not in the media directory).
|
||||
// converted minimally on the fly. Returns ErrSubtitleNotFound when the
|
||||
// path is rejected (path traversal / not in the media directory).
|
||||
func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.Writer) error {
|
||||
m, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil || m == nil {
|
||||
return errors.New("media not found")
|
||||
}
|
||||
if strings.HasPrefix(sub, "embedded:") {
|
||||
return s.ServeEmbeddedToVTT(ctx, m.Path, sub, w)
|
||||
}
|
||||
abs, err := filepath.Abs(sub)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -266,81 +178,6 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W
|
||||
return err
|
||||
}
|
||||
|
||||
// embeddedSubtitleCachePath 内嵌字幕提取后的 WebVTT 缓存路径
|
||||
// (按媒体路径哈希 + 轨道号定位,跨媒体互不干扰)。
|
||||
func (s *SubtitleService) embeddedSubtitleCachePath(mediaPath string, idx int) string {
|
||||
hash := fmt.Sprintf("%x", fnvHash(mediaPath))
|
||||
return filepath.Join(s.cfg.Cache.CacheDir, "subs", hash, fmt.Sprintf("s%d.vtt", idx))
|
||||
}
|
||||
|
||||
// ServeEmbeddedToVTT 把容器内第 idx 个字幕轨提取为 WebVTT 输出。
|
||||
// 提取结果缓存在 cache 目录,媒体文件更新(mtime 变化)后自动重新提取。
|
||||
func (s *SubtitleService) ServeEmbeddedToVTT(ctx context.Context, mediaPath, streamRef string, w io.Writer) error {
|
||||
idx, err := strconv.Atoi(strings.TrimPrefix(streamRef, "embedded:"))
|
||||
if err != nil || idx < 0 {
|
||||
return errors.New("invalid embedded subtitle index")
|
||||
}
|
||||
ffmpegBin, err := resolveLocalExecutable(s.cfg.App.FFmpegPath, "ffmpeg")
|
||||
if err != nil {
|
||||
return fmt.Errorf("ffmpeg 不可用,无法提取内嵌字幕:%w", err)
|
||||
}
|
||||
info, err := os.Stat(mediaPath)
|
||||
if err != nil {
|
||||
return errors.New("media file not found")
|
||||
}
|
||||
|
||||
cachePath := s.embeddedSubtitleCachePath(mediaPath, idx)
|
||||
|
||||
if cached, statErr := os.Stat(cachePath); statErr == nil && !info.ModTime().After(cached.ModTime()) {
|
||||
f, openErr := os.Open(cachePath) // #nosec G304 -- cachePath is generated under the cache dir.
|
||||
if openErr == nil {
|
||||
defer f.Close()
|
||||
_, copyErr := io.Copy(w, f)
|
||||
return copyErr
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存未命中或媒体已更新:ffmpeg 提取到临时文件后原子改名。
|
||||
if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := cachePath + ".tmp"
|
||||
extractCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(extractCtx, ffmpegBin, // #nosec G204 -- bin resolved by resolveLocalExecutable; args fixed extraction.
|
||||
"-v", "error", "-y",
|
||||
"-i", mediaPath,
|
||||
"-map", "0:s:"+strconv.Itoa(idx),
|
||||
"-f", "webvtt",
|
||||
tmp,
|
||||
)
|
||||
if out, runErr := cmd.CombinedOutput(); runErr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("提取内嵌字幕失败(轨道 %d,可能为位图字幕或轨道无效):%s", idx, strings.TrimSpace(string(out)))
|
||||
}
|
||||
if err := os.Rename(tmp, cachePath); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
f, err := os.Open(cachePath) // #nosec G304 -- cachePath is generated under the cache dir.
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(w, f)
|
||||
return err
|
||||
}
|
||||
|
||||
// fnvHash 简单 32 位 FNV-1a 哈希,用于生成稳定的缓存子目录名。
|
||||
func fnvHash(s string) uint32 {
|
||||
var h uint32 = 2166136261
|
||||
for i := 0; i < len(s); i++ {
|
||||
h ^= uint32(s[i])
|
||||
h *= 16777619
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// ServeRaw writes the subtitle file in its original format without any
|
||||
// WebVTT conversion. Emby/Jellyfin clients advertise the source codec (ASS,
|
||||
// subrip, etc.) in MediaStreams, then fetch the subtitle bytes via the
|
||||
@@ -368,4 +205,4 @@ func (s *SubtitleService) ServeRaw(ctx context.Context, mediaID, sub string, w i
|
||||
defer f.Close()
|
||||
_, err = io.Copy(w, f)
|
||||
return err
|
||||
}
|
||||
}
|
||||
+69
-18
@@ -1,6 +1,45 @@
|
||||
import { api } from './client'
|
||||
import type { AccessLog, Setting, User } from '../types'
|
||||
|
||||
export interface DatabaseStatus {
|
||||
type: 'sqlite' | 'postgres'
|
||||
dsn?: string
|
||||
db_path?: string
|
||||
open_conns: number
|
||||
in_use: number
|
||||
idle: number
|
||||
max_open_conns: number
|
||||
table_counts?: Record<string, number>
|
||||
}
|
||||
|
||||
export interface PostgresTestResult {
|
||||
success: boolean
|
||||
latency_ms?: number
|
||||
version?: string
|
||||
message?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface DatabaseMigrationResult {
|
||||
success: boolean
|
||||
total_rows: number
|
||||
table_rows?: Record<string, number>
|
||||
duration_ms: number
|
||||
message?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface DatabaseConnectionPayload {
|
||||
type?: string
|
||||
dsn?: string
|
||||
host?: string
|
||||
port?: number
|
||||
user?: string
|
||||
password?: string
|
||||
dbname?: string
|
||||
sslmode?: string
|
||||
}
|
||||
|
||||
export interface SystemUpdateStatus {
|
||||
image: string
|
||||
current_version?: string
|
||||
@@ -55,21 +94,33 @@ export const adminAPI = {
|
||||
|
||||
systemUpdateApply: () => api.post<SystemUpdateStatus>('/admin/system/update/apply').then((r) => r.data),
|
||||
|
||||
testAdultScraper: (payload: {
|
||||
engine?: string
|
||||
server_url?: string
|
||||
token?: string
|
||||
javdb_url?: string
|
||||
javbus_url?: string
|
||||
cookie?: string
|
||||
}) =>
|
||||
api
|
||||
.post<{
|
||||
success: boolean
|
||||
latency_ms?: number
|
||||
providers?: string[]
|
||||
message?: string
|
||||
error?: string
|
||||
}>('/admin/adult/test-scraper', payload)
|
||||
.then((r) => r.data),
|
||||
}
|
||||
testAdultScraper: (payload: {
|
||||
engine?: string
|
||||
server_url?: string
|
||||
token?: string
|
||||
javdb_url?: string
|
||||
javbus_url?: string
|
||||
cookie?: string
|
||||
}) =>
|
||||
api
|
||||
.post<{
|
||||
success: boolean
|
||||
latency_ms?: number
|
||||
providers?: string[]
|
||||
message?: string
|
||||
error?: string
|
||||
}>('/admin/adult/test-scraper', payload)
|
||||
.then((r) => r.data),
|
||||
|
||||
getDatabaseStatus: () =>
|
||||
api.get<DatabaseStatus>('/admin/database/status').then((r) => r.data),
|
||||
|
||||
testDatabaseConnection: (payload: DatabaseConnectionPayload) =>
|
||||
api.post<PostgresTestResult>('/admin/database/test', payload).then((r) => r.data),
|
||||
|
||||
migrateDatabase: (payload: DatabaseConnectionPayload) =>
|
||||
api.post<DatabaseMigrationResult>('/admin/database/migrate', payload).then((r) => r.data),
|
||||
|
||||
saveDatabaseConfig: (payload: DatabaseConnectionPayload) =>
|
||||
api.post<{ message: string; type: string }>('/admin/database/save-config', payload).then((r) => r.data),
|
||||
}
|
||||
|
||||
+15
-3
@@ -81,11 +81,20 @@ export interface MediaMetadataUpdate {
|
||||
nsfw?: boolean
|
||||
}
|
||||
|
||||
export interface LibraryWithPreview extends Library {
|
||||
total?: number
|
||||
cards?: SeriesCard[]
|
||||
}
|
||||
|
||||
export const libraryAPI = {
|
||||
list: (options?: { includeHidden?: boolean }) =>
|
||||
list: (options?: { includeHidden?: boolean; withPreview?: boolean; previewLimit?: number }) =>
|
||||
api
|
||||
.get<Library[]>('/libraries', {
|
||||
params: options?.includeHidden ? { include_hidden: 1 } : undefined,
|
||||
.get<LibraryWithPreview[]>('/libraries', {
|
||||
params: {
|
||||
...(options?.includeHidden ? { include_hidden: 1 } : {}),
|
||||
...(options?.withPreview ? { with_preview: 1 } : {}),
|
||||
...(options?.previewLimit ? { preview_limit: options.previewLimit } : {}),
|
||||
},
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
@@ -188,6 +197,9 @@ export const mediaAPI = {
|
||||
|
||||
get: (id: string) => api.get<Media>(`/media/${id}`).then((r) => r.data),
|
||||
|
||||
getEpisodes: (id: string) =>
|
||||
api.get<{ items: Media[]; total: number }>(`/media/${id}/episodes`).then((r) => r.data),
|
||||
|
||||
updateMetadata: (id: string, payload: MediaMetadataUpdate) =>
|
||||
api.patch<Media>(`/media/${id}/metadata`, payload, { timeout: LONG_REQUEST_TIMEOUT }).then((r) => r.data),
|
||||
|
||||
|
||||
@@ -67,6 +67,24 @@ export interface OrganizeResultSummary {
|
||||
}>
|
||||
}
|
||||
|
||||
export interface FFmpegToolInfo {
|
||||
installed: boolean
|
||||
path?: string
|
||||
version?: string
|
||||
}
|
||||
|
||||
export interface FFmpegToolsStatus {
|
||||
installing?: boolean
|
||||
message?: string
|
||||
error?: string
|
||||
started_at?: string
|
||||
finished_at?: string
|
||||
install_dir?: string
|
||||
target?: { os?: string; arch?: string; label?: string }
|
||||
ffmpeg?: FFmpegToolInfo
|
||||
ffprobe?: FFmpegToolInfo
|
||||
}
|
||||
|
||||
export const toolsAPI = {
|
||||
organizeMedia: (mediaID: string, opts?: OrganizeOverrides) =>
|
||||
api
|
||||
@@ -102,6 +120,14 @@ export const toolsAPI = {
|
||||
.post<{ message: string }>('/admin/notify/test', { title, body })
|
||||
.then((r) => r.data),
|
||||
|
||||
// ffToolsStatus 查询 ffmpeg/ffprobe 的安装状态(路径/版本/是否正在安装)。
|
||||
ffToolsStatus: () =>
|
||||
api.get<FFmpegToolsStatus>('/admin/tools/ffmpeg/status').then((r) => r.data),
|
||||
|
||||
// installFFTools 触发后台下载安装 ffmpeg/ffprobe(自动匹配当前平台)。
|
||||
installFFTools: () =>
|
||||
api.post<FFmpegToolsStatus>('/admin/tools/ffmpeg/install').then((r) => r.data),
|
||||
|
||||
// repairAndRescrapeAll 触发「全库修复+重刮」:先从媒体路径中的
|
||||
// {tmdb-N}/{bangumi-N} 占位符回填缺失/错误的外部 ID,再批量重刮整库。
|
||||
// 后端异步执行,立即返回;进度通过 WS "scrape" topic 推送。
|
||||
|
||||
@@ -42,6 +42,7 @@ export function ExternalPlayerButton({
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
title={compact ? '使用外部播放器播放' : undefined}
|
||||
disabled={loading}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
@@ -50,7 +51,7 @@ export function ExternalPlayerButton({
|
||||
}}
|
||||
className={
|
||||
compact
|
||||
? 'rounded-lg border border-primary-400/35 bg-white px-2 py-1 text-xs font-semibold text-brand-500 hover:bg-primary-400/10 disabled:opacity-50'
|
||||
? 'shrink-0 inline-flex items-center rounded-lg border border-primary-400/35 bg-white px-2 py-1 text-xs font-semibold text-brand-500 hover:bg-primary-400/10 disabled:opacity-50 transition-colors whitespace-nowrap'
|
||||
: 'btn-outline border-brand-500/30 px-5 text-[#c9954a] hover:border-brand-500 hover:bg-brand-50'
|
||||
}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { CheckCircle2, Download, Loader2, XCircle } from 'lucide-react'
|
||||
|
||||
import { toolsAPI, type FFmpegToolInfo, type FFmpegToolsStatus } from '../api/tools'
|
||||
|
||||
// FFToolsPanel 展示 ffmpeg/ffprobe 的安装状态,并提供「一键下载安装」:
|
||||
// 后端自动匹配当前运行环境(OS + 架构),下载到 data 目录并写入设置,
|
||||
// 无需手动填写路径。
|
||||
export function FFToolsPanel({ onInstalled }: { onInstalled?: () => void }) {
|
||||
const [status, setStatus] = useState<FFmpegToolsStatus | null>(null)
|
||||
const [working, setWorking] = useState(false)
|
||||
const pollingRef = useRef(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setStatus(await toolsAPI.ffToolsStatus())
|
||||
} catch {
|
||||
// 网络波动时保持旧状态,不打断页面
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load().catch(() => undefined)
|
||||
}, [load])
|
||||
|
||||
const installing = Boolean(status?.installing)
|
||||
|
||||
// 安装进行中:轮询状态直到结束,完成后刷新设置页(路径字段自动填入)。
|
||||
useEffect(() => {
|
||||
if (!installing || pollingRef.current) return
|
||||
pollingRef.current = true
|
||||
const timer = window.setInterval(async () => {
|
||||
try {
|
||||
const next = await toolsAPI.ffToolsStatus()
|
||||
setStatus(next)
|
||||
if (!next.installing) {
|
||||
window.clearInterval(timer)
|
||||
pollingRef.current = false
|
||||
if (next.error) {
|
||||
toast.error(`FFmpeg 下载安装失败:${next.error}`)
|
||||
} else {
|
||||
toast.success('FFmpeg / FFprobe 下载安装完成,系统已自动使用')
|
||||
onInstalled?.()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 轮询失败继续等下一轮
|
||||
}
|
||||
}, 1500)
|
||||
return () => {
|
||||
window.clearInterval(timer)
|
||||
pollingRef.current = false
|
||||
}
|
||||
}, [installing, onInstalled])
|
||||
|
||||
const install = async () => {
|
||||
setWorking(true)
|
||||
try {
|
||||
const next = await toolsAPI.installFFTools()
|
||||
setStatus(next)
|
||||
if (next.error && !next.installing) {
|
||||
toast.error(next.error)
|
||||
} else if (next.installing) {
|
||||
toast('开始下载,完成后自动生效')
|
||||
} else {
|
||||
toast.success('已检测到可用工具')
|
||||
onInstalled?.()
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
(err as { message?: string })?.message ??
|
||||
'启动下载失败',
|
||||
)
|
||||
} finally {
|
||||
setWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const busy = working || installing
|
||||
const targetLabel = status?.target?.label ?? '当前平台'
|
||||
const ffmpegInstalled = Boolean(status?.ffmpeg?.installed)
|
||||
const ffprobeInstalled = Boolean(status?.ffprobe?.installed)
|
||||
|
||||
return (
|
||||
<div className="glass-panel space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-display text-lg font-bold text-ink-600">FFmpeg / FFprobe 工具</div>
|
||||
<div className="text-xs text-sand-500">
|
||||
当前平台:{targetLabel} · 安装目录:
|
||||
<span className="font-mono">{status?.install_dir ?? '…'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={install}
|
||||
disabled={busy}
|
||||
className="neon-button shrink-0 disabled:opacity-50"
|
||||
>
|
||||
{busy ? <Loader2 size={16} className="animate-spin" /> : <Download size={16} />}
|
||||
{busy ? '下载安装中…' : '下载并安装'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<ToolRow name="FFmpeg" info={status?.ffmpeg} />
|
||||
<ToolRow name="FFprobe" info={status?.ffprobe} />
|
||||
</div>
|
||||
|
||||
{(status?.message || status?.error) && (
|
||||
<div
|
||||
className={
|
||||
status?.error
|
||||
? 'rounded-xl bg-red-50 px-3 py-2 text-xs text-red-600'
|
||||
: 'rounded-xl bg-sand-100/70 px-3 py-2 text-xs text-sand-500'
|
||||
}
|
||||
>
|
||||
{status?.error || status?.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!ffmpegInstalled || !ffprobeInstalled ? (
|
||||
<div className="text-xs text-sand-500">
|
||||
未检测到可用工具。点击「下载并安装」后,服务端会自动匹配当前系统下载对应版本,安装完成后无需手动填写路径。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolRow({ name, info }: { name: string; info?: FFmpegToolInfo }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-gray-200 bg-white/70 px-3 py-2">
|
||||
{info?.installed ? (
|
||||
<CheckCircle2 size={16} className="shrink-0 text-green-500" />
|
||||
) : (
|
||||
<XCircle size={16} className="shrink-0 text-red-400" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-ink-600">
|
||||
{name} {info?.installed ? '' : '(未安装)'}
|
||||
</div>
|
||||
{info?.installed && (
|
||||
<div className="truncate font-mono text-[11px] text-ink-50" title={info.path}>
|
||||
{info.path}
|
||||
</div>
|
||||
)}
|
||||
{info?.version && <div className="truncate text-[11px] text-sand-500">{info.version}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, Check, Shuffle } from 'lucide-react'
|
||||
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
type SortField,
|
||||
type SortOption,
|
||||
type SortOrder,
|
||||
} from '../utils/mediaSort'
|
||||
|
||||
type MediaSortDropdownProps = {
|
||||
value: SortField
|
||||
order: SortOrder
|
||||
onChange: (field: SortField, order: SortOrder) => void
|
||||
onReshuffle?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MediaSortDropdown({
|
||||
value,
|
||||
order,
|
||||
onChange,
|
||||
onReshuffle,
|
||||
className = '',
|
||||
}: MediaSortDropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const currentOption = SORT_OPTIONS.find((opt) => opt.id === value) ?? SORT_OPTIONS[0]
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const handleSelect = (option: SortOption) => {
|
||||
if (option.id === 'random') {
|
||||
if (value === 'random') {
|
||||
onReshuffle?.()
|
||||
} else {
|
||||
onChange('random', 'desc')
|
||||
}
|
||||
setIsOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (value === option.id) {
|
||||
// 再次点击相同项:切换升降序
|
||||
const nextOrder: SortOrder = order === 'asc' ? 'desc' : 'asc'
|
||||
onChange(option.id, nextOrder)
|
||||
} else {
|
||||
// 点击新项:应用默认排序方向
|
||||
onChange(option.id, option.defaultOrder)
|
||||
}
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative inline-block text-left ${className}`} ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-xl border border-sand-200 bg-white/90 px-3 py-1.5 text-xs font-semibold text-ink-600 shadow-sm transition-all hover:border-brand-300 hover:bg-brand-50/50 hover:text-brand-700"
|
||||
title="更改排序方式"
|
||||
>
|
||||
<ArrowUpDown size={14} className="text-sand-500" />
|
||||
<span>排序: {currentOption.label}</span>
|
||||
{value === 'random' ? (
|
||||
<Shuffle size={12} className="text-brand-600" />
|
||||
) : order === 'asc' ? (
|
||||
<ArrowUp size={13} className="text-brand-600 font-bold" />
|
||||
) : (
|
||||
<ArrowDown size={13} className="text-brand-600 font-bold" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 z-50 mt-1.5 w-52 origin-top-right rounded-2xl border border-sand-200/80 bg-white/95 p-1.5 shadow-xl backdrop-blur-md transition-all animate-in fade-in-0 zoom-in-95">
|
||||
<div className="px-2.5 py-1.5 text-[11px] font-bold text-sand-500 border-b border-sand-100">
|
||||
排序方式
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto py-1 space-y-0.5">
|
||||
{SORT_OPTIONS.map((option) => {
|
||||
const isSelected = value === option.id
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(option)}
|
||||
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 text-xs font-medium transition-colors ${
|
||||
isSelected
|
||||
? 'bg-brand-50 text-brand-700 font-semibold'
|
||||
: 'text-ink-600 hover:bg-sand-50 hover:text-brand-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSelected && <Check size={13} className="text-brand-600" />}
|
||||
<span className={isSelected ? '' : 'pl-5'}>{option.label}</span>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="flex items-center text-brand-600">
|
||||
{option.id === 'random' ? (
|
||||
<Shuffle size={13} />
|
||||
) : order === 'asc' ? (
|
||||
<ArrowUp size={14} className="stroke-[2.5]" />
|
||||
) : (
|
||||
<ArrowDown size={14} className="stroke-[2.5]" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,12 +2,15 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Captions,
|
||||
CaptionsOff,
|
||||
ListVideo,
|
||||
Maximize,
|
||||
MessageSquareText,
|
||||
Minimize,
|
||||
Pause,
|
||||
PictureInPicture,
|
||||
Play,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-react'
|
||||
@@ -34,6 +37,15 @@ type PlayerControlsProps = {
|
||||
danmakuOpen: boolean
|
||||
danmakuEnabled: boolean
|
||||
onToggleDanmaku: () => void
|
||||
hasPrevEpisode?: boolean
|
||||
hasNextEpisode?: boolean
|
||||
onPrevEpisode?: () => void
|
||||
onNextEpisode?: () => void
|
||||
prevEpisodeTitle?: string
|
||||
nextEpisodeTitle?: string
|
||||
playlistOpen?: boolean
|
||||
hasPlaylist?: boolean
|
||||
onTogglePlaylist?: () => void
|
||||
}
|
||||
|
||||
export function PlayerControls({
|
||||
@@ -44,6 +56,15 @@ export function PlayerControls({
|
||||
danmakuOpen,
|
||||
danmakuEnabled,
|
||||
onToggleDanmaku,
|
||||
hasPrevEpisode = false,
|
||||
hasNextEpisode = false,
|
||||
onPrevEpisode,
|
||||
onNextEpisode,
|
||||
prevEpisodeTitle,
|
||||
nextEpisodeTitle,
|
||||
playlistOpen = false,
|
||||
hasPlaylist = false,
|
||||
onTogglePlaylist,
|
||||
}: PlayerControlsProps) {
|
||||
const video = () => videoRef.current
|
||||
const container = () =>
|
||||
@@ -69,6 +90,7 @@ export function PlayerControls({
|
||||
const isScrubbingRef = useRef(false)
|
||||
const subtitleMenuOpenRef = useRef(false)
|
||||
const danmakuOpenRef = useRef(false)
|
||||
const playlistOpenRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
controlsHoveredRef.current = controlsHovered
|
||||
@@ -86,6 +108,10 @@ export function PlayerControls({
|
||||
danmakuOpenRef.current = danmakuOpen
|
||||
}, [danmakuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
playlistOpenRef.current = playlistOpen
|
||||
}, [playlistOpen])
|
||||
|
||||
// 点击控制栏外部时关闭字幕菜单
|
||||
useEffect(() => {
|
||||
if (!subtitleMenuOpen) return
|
||||
@@ -112,14 +138,16 @@ export function PlayerControls({
|
||||
!controlsHoveredRef.current &&
|
||||
!isScrubbingRef.current &&
|
||||
!subtitleMenuOpenRef.current &&
|
||||
!danmakuOpenRef.current
|
||||
!danmakuOpenRef.current &&
|
||||
!playlistOpenRef.current
|
||||
) {
|
||||
hideTimerRef.current = setTimeout(() => {
|
||||
if (
|
||||
!controlsHoveredRef.current &&
|
||||
!isScrubbingRef.current &&
|
||||
!subtitleMenuOpenRef.current &&
|
||||
!danmakuOpenRef.current
|
||||
!danmakuOpenRef.current &&
|
||||
!playlistOpenRef.current
|
||||
) {
|
||||
setUiVisible(false)
|
||||
}
|
||||
@@ -137,7 +165,7 @@ export function PlayerControls({
|
||||
if (e.relatedTarget && stage.contains(e.relatedTarget as Node)) {
|
||||
return
|
||||
}
|
||||
if (el.paused || controlsHoveredRef.current || isScrubbingRef.current) return
|
||||
if (el.paused || controlsHoveredRef.current || isScrubbingRef.current || playlistOpenRef.current) return
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
setUiVisible(false)
|
||||
}
|
||||
@@ -201,7 +229,7 @@ export function PlayerControls({
|
||||
|
||||
// 当悬停或菜单状态改变时,更新控制栏计时器
|
||||
useEffect(() => {
|
||||
if (controlsHovered || isScrubbing || subtitleMenuOpen || danmakuOpen) {
|
||||
if (controlsHovered || isScrubbing || subtitleMenuOpen || danmakuOpen || playlistOpen) {
|
||||
setUiVisible(true)
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
} else {
|
||||
@@ -211,7 +239,7 @@ export function PlayerControls({
|
||||
hideTimerRef.current = setTimeout(() => setUiVisible(false), 3000)
|
||||
}
|
||||
}
|
||||
}, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen])
|
||||
}, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen, playlistOpen])
|
||||
|
||||
const togglePlay = () => {
|
||||
const el = video()
|
||||
@@ -297,14 +325,43 @@ export function PlayerControls({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 text-white">
|
||||
{/* 上一集 */}
|
||||
<button
|
||||
onClick={onPrevEpisode}
|
||||
disabled={!hasPrevEpisode}
|
||||
className={`rounded-full p-1.5 transition ${
|
||||
hasPrevEpisode
|
||||
? 'hover:bg-white/15 text-white cursor-pointer'
|
||||
: 'text-white/30 cursor-not-allowed opacity-40'
|
||||
}`}
|
||||
title={hasPrevEpisode ? (prevEpisodeTitle ? `上一集:${prevEpisodeTitle} ([)` : '上一集 ([)') : '没有上一集'}
|
||||
>
|
||||
<SkipBack size={18} />
|
||||
</button>
|
||||
|
||||
{/* 播放 / 暂停 */}
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title={playing ? '暂停' : '播放'}
|
||||
title={playing ? '暂停 (Space)' : '播放 (Space)'}
|
||||
>
|
||||
{playing ? <Pause size={20} /> : <Play size={20} />}
|
||||
</button>
|
||||
|
||||
{/* 下一集 */}
|
||||
<button
|
||||
onClick={onNextEpisode}
|
||||
disabled={!hasNextEpisode}
|
||||
className={`rounded-full p-1.5 transition ${
|
||||
hasNextEpisode
|
||||
? 'hover:bg-white/15 text-white cursor-pointer'
|
||||
: 'text-white/30 cursor-not-allowed opacity-40'
|
||||
}`}
|
||||
title={hasNextEpisode ? (nextEpisodeTitle ? `下一集:${nextEpisodeTitle} (])` : '下一集 (])') : '没有下一集'}
|
||||
>
|
||||
<SkipForward size={18} />
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
@@ -383,6 +440,27 @@ export function PlayerControls({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 选集 / 播放列表按钮 */}
|
||||
{onTogglePlaylist && (
|
||||
<button
|
||||
onClick={onTogglePlaylist}
|
||||
disabled={!hasPlaylist}
|
||||
className={
|
||||
'flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-xs font-medium transition ' +
|
||||
(!hasPlaylist
|
||||
? 'bg-white/5 text-white/30 cursor-not-allowed opacity-50'
|
||||
: playlistOpen
|
||||
? 'bg-rose-500 text-white hover:bg-rose-600'
|
||||
: 'bg-white/10 text-white/80 hover:bg-white/20')
|
||||
}
|
||||
title={hasPlaylist ? '选集列表' : '当前无更多剧集'}
|
||||
>
|
||||
<ListVideo size={15} />
|
||||
选集
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 弹幕按钮 */}
|
||||
<button
|
||||
onClick={onToggleDanmaku}
|
||||
className={
|
||||
@@ -401,7 +479,7 @@ export function PlayerControls({
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title={muted || volume === 0 ? '取消静音' : '静音'}
|
||||
title={muted || volume === 0 ? '取消静音 (M)' : '静音 (M)'}
|
||||
>
|
||||
{muted || volume === 0 ? <VolumeX size={18} /> : <Volume2 size={18} />}
|
||||
</button>
|
||||
@@ -419,7 +497,7 @@ export function PlayerControls({
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title={fullscreen ? '退出全屏' : '全屏'}
|
||||
title={fullscreen ? '退出全屏 (F)' : '全屏 (F)'}
|
||||
>
|
||||
{fullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, Film, ListVideo, Play, Search, X } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
import { seriesTitleFromPath } from '../utils/groupSeries'
|
||||
|
||||
export type SeasonGroup = {
|
||||
season: number
|
||||
episodes: Media[]
|
||||
}
|
||||
|
||||
type PlayerPlaylistPanelProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
currentMediaId: string
|
||||
episodes: Media[]
|
||||
onSelectEpisode: (media: Media) => void
|
||||
}
|
||||
|
||||
export function PlayerPlaylistPanel({
|
||||
open,
|
||||
onClose,
|
||||
currentMediaId,
|
||||
episodes,
|
||||
onSelectEpisode,
|
||||
}: PlayerPlaylistPanelProps) {
|
||||
const [filterText, setFilterText] = useState('')
|
||||
const activeItemRef = useRef<HTMLDivElement | null>(null)
|
||||
const listContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// 按季分组
|
||||
const seasonGroups = useMemo<SeasonGroup[]>(() => {
|
||||
if (!episodes || episodes.length === 0) return []
|
||||
const seasonsMap = new Map<number, Media[]>()
|
||||
for (const ep of episodes) {
|
||||
const s = ep.episode_num > 0 ? (ep.season_num ?? 0) : (ep.season_num || 1)
|
||||
if (!seasonsMap.has(s)) seasonsMap.set(s, [])
|
||||
seasonsMap.get(s)!.push(ep)
|
||||
}
|
||||
for (const [, list] of seasonsMap) {
|
||||
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
|
||||
}
|
||||
return Array.from(seasonsMap.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([season, list]) => ({ season, episodes: list }))
|
||||
}, [episodes])
|
||||
|
||||
// 当前播放所在季
|
||||
const currentSeason = useMemo(() => {
|
||||
const found = episodes.find((e) => e.id === currentMediaId)
|
||||
if (!found) return seasonGroups[0]?.season ?? 1
|
||||
return found.episode_num > 0 ? (found.season_num ?? 0) : (found.season_num || 1)
|
||||
}, [episodes, currentMediaId, seasonGroups])
|
||||
|
||||
const [selectedSeason, setSelectedSeason] = useState<number>(currentSeason)
|
||||
|
||||
// 当当前播放媒体改变或打开面板时,默认选中当前媒体所在的季
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedSeason(currentSeason)
|
||||
}
|
||||
}, [open, currentSeason])
|
||||
|
||||
// 当面板打开时,自动平滑滚动到当前播放集的位置
|
||||
useEffect(() => {
|
||||
if (open && activeItemRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
activeItemRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}, 100)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [open, selectedSeason, currentMediaId])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const currentGroup = seasonGroups.find((g) => g.season === selectedSeason) ?? seasonGroups[0]
|
||||
const listToDisplay = currentGroup ? currentGroup.episodes : episodes
|
||||
|
||||
const filteredEpisodes = filterText.trim()
|
||||
? listToDisplay.filter((ep) => {
|
||||
const query = filterText.trim().toLowerCase()
|
||||
const title = (ep.episode_title || ep.title || '').toLowerCase()
|
||||
const epNum = String(ep.episode_num)
|
||||
return title.includes(query) || epNum === query || `e${epNum}`.includes(query) || `第${epNum}集`.includes(query)
|
||||
})
|
||||
: listToDisplay
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute right-4 top-16 bottom-20 z-30 flex w-80 sm:w-96 flex-col rounded-2xl border border-white/15 bg-black/85 text-white shadow-2xl backdrop-blur-md overflow-hidden"
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 shrink-0">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<ListVideo size={17} className="text-rose-400" />
|
||||
<span>选集列表</span>
|
||||
<span className="font-mono text-xs font-normal text-white/50">
|
||||
({episodes.length} 集)
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-full p-1 text-white/60 transition hover:bg-white/10 hover:text-white"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 季选择 Tabs(若有多季) */}
|
||||
{seasonGroups.length > 1 && (
|
||||
<div className="flex items-center gap-1.5 border-b border-white/10 px-3 py-2 shrink-0 overflow-x-auto no-scrollbar">
|
||||
{seasonGroups.map(({ season, episodes: sesEps }) => {
|
||||
const isSelected = selectedSeason === season
|
||||
const isPlayingThisSeason = sesEps.some((e) => e.id === currentMediaId)
|
||||
return (
|
||||
<button
|
||||
key={season}
|
||||
onClick={() => {
|
||||
setSelectedSeason(season)
|
||||
setFilterText('')
|
||||
}}
|
||||
className={`relative flex shrink-0 items-center gap-1 rounded-lg px-2.5 py-1 text-xs font-medium transition ${
|
||||
isSelected
|
||||
? 'bg-rose-500 text-white'
|
||||
: 'bg-white/5 text-white/70 hover:bg-white/10 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span>{season === 0 ? '特别篇' : `第 ${season} 季`}</span>
|
||||
<span className="text-[10px] opacity-75">({sesEps.length})</span>
|
||||
{isPlayingThisSeason && !isSelected && (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-rose-400" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜索/过滤单集(当单集数量较多时) */}
|
||||
{listToDisplay.length > 10 && (
|
||||
<div className="px-3 pt-2.5 pb-1.5 shrink-0">
|
||||
<div className="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs">
|
||||
<Search size={13} className="text-white/40 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
placeholder="搜索集数或标题…"
|
||||
className="w-full bg-transparent outline-none placeholder:text-white/30 text-white text-xs"
|
||||
/>
|
||||
{filterText && (
|
||||
<button
|
||||
onClick={() => setFilterText('')}
|
||||
className="text-white/40 hover:text-white"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 集数列表 */}
|
||||
<div
|
||||
ref={listContainerRef}
|
||||
className="flex-1 overflow-y-auto p-2.5 space-y-1.5 pr-2 select-none"
|
||||
>
|
||||
{filteredEpisodes.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-white/40">
|
||||
{filterText ? '未找到匹配的剧集' : '暂无剧集列表'}
|
||||
</div>
|
||||
) : (
|
||||
filteredEpisodes.map((ep) => {
|
||||
const isPlaying = ep.id === currentMediaId
|
||||
const displayTitle = getEpisodeTitle(ep, listToDisplay)
|
||||
const durationText =
|
||||
ep.duration_sec > 0 ? `${Math.floor(ep.duration_sec / 60)} 分钟` : ''
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ep.id}
|
||||
ref={isPlaying ? activeItemRef : null}
|
||||
onClick={() => onSelectEpisode(ep)}
|
||||
className={`group flex cursor-pointer items-center gap-2.5 rounded-xl p-2 transition border ${
|
||||
isPlaying
|
||||
? 'border-rose-500/60 bg-rose-500/20 text-white'
|
||||
: 'border-white/5 bg-white/5 hover:border-white/20 hover:bg-white/10 text-white/85'
|
||||
}`}
|
||||
>
|
||||
{/* 封面/集号 */}
|
||||
<div className="relative flex h-11 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/10 text-xs font-semibold">
|
||||
{ep.backdrop_url || ep.poster_url ? (
|
||||
<img
|
||||
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<Film size={16} className="text-white/40" />
|
||||
)}
|
||||
|
||||
{/* 正在播放动效 / 集数徽标 */}
|
||||
{isPlaying ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/60 backdrop-blur-xs">
|
||||
<div className="flex items-end gap-0.5 h-3">
|
||||
<span className="w-0.5 bg-rose-400 animate-pulse h-full" />
|
||||
<span className="w-0.5 bg-rose-400 animate-pulse h-2" />
|
||||
<span className="w-0.5 bg-rose-400 animate-pulse h-3" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Play size={14} className="text-white fill-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 角标显示集数 */}
|
||||
<span className="absolute bottom-0.5 right-1 rounded bg-black/75 px-1 py-0.2 text-[9px] font-mono text-white/90">
|
||||
{ep.episode_num > 0 ? `${ep.episode_num}` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 标题 & 时长 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p
|
||||
className={`truncate text-xs font-medium ${
|
||||
isPlaying ? 'text-rose-300 font-semibold' : 'group-hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{displayTitle}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-white/50">
|
||||
{ep.episode_num > 0 && (
|
||||
<span className="font-mono">第 {ep.episode_num} 集</span>
|
||||
)}
|
||||
{durationText && <span>{durationText}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPlaying && (
|
||||
<div className="shrink-0 flex items-center gap-1 text-[11px] font-medium text-rose-400 px-1">
|
||||
<Check size={13} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getEpisodeTitle(ep: Media, siblings: Media[]): string {
|
||||
const title = ep.episode_title?.trim()
|
||||
if (title && !looksLikeSeriesTitle(ep, title, siblings)) {
|
||||
return title
|
||||
}
|
||||
|
||||
const mediaTitle = ep.title?.trim()
|
||||
if (mediaTitle && !looksLikeSeriesTitle(ep, mediaTitle, siblings)) {
|
||||
return mediaTitle
|
||||
}
|
||||
|
||||
return ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : mediaTitle || title || '未命名'
|
||||
}
|
||||
|
||||
function looksLikeSeriesTitle(ep: Media, title: string, siblings: Media[]): boolean {
|
||||
const normalized = normalizeEpisodeTitle(title)
|
||||
if (!normalized) return true
|
||||
if (ep.original_name && normalizeEpisodeTitle(ep.original_name) === normalized) return true
|
||||
const pathTitle = seriesTitleFromPath(ep.path)
|
||||
if (pathTitle && normalizeEpisodeTitle(pathTitle) === normalized) return true
|
||||
|
||||
const siblingTitles = new Set(
|
||||
siblings
|
||||
.map((item) => normalizeEpisodeTitle(item.title))
|
||||
.filter(Boolean),
|
||||
)
|
||||
return siblingTitles.size === 1 && siblingTitles.has(normalized) && siblings.length > 1
|
||||
}
|
||||
|
||||
function normalizeEpisodeTitle(value?: string): string {
|
||||
return (value ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/\s*\((?:19|20)\d{2}\)\s*/g, ' ')
|
||||
.replace(/\s*\{(?:tmdb|tmdbid|douban|bangumi|bgm|thetvdb|tvdb)[\s:=#-]*[a-z0-9_-]+\}\s*/g, ' ')
|
||||
.replace(/[\s._-]+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
Activity,
|
||||
ArrowRightLeft,
|
||||
CheckCircle2,
|
||||
Database,
|
||||
HardDrive,
|
||||
HelpCircle,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
|
||||
import {
|
||||
adminAPI,
|
||||
type DatabaseConnectionPayload,
|
||||
type DatabaseMigrationResult,
|
||||
type DatabaseStatus,
|
||||
type PostgresTestResult,
|
||||
} from '../api/admin'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
|
||||
export function DatabaseSettingsPanel() {
|
||||
const [status, setStatus] = useState<DatabaseStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [mode, setMode] = useState<'form' | 'dsn'>('form')
|
||||
|
||||
// 表单状态
|
||||
const [formData, setFormData] = useState<DatabaseConnectionPayload>({
|
||||
host: '127.0.0.1',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
password: '',
|
||||
dbname: 'mmtl',
|
||||
sslmode: 'disable',
|
||||
dsn: '',
|
||||
})
|
||||
|
||||
// 测试与操作状态
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<PostgresTestResult | null>(null)
|
||||
const [migrating, setMigrating] = useState(false)
|
||||
const [migrationResult, setMigrationResult] = useState<DatabaseMigrationResult | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const refreshStatus = () => {
|
||||
setLoading(true)
|
||||
return adminAPI
|
||||
.getDatabaseStatus()
|
||||
.then(setStatus)
|
||||
.catch((err) => toast.error('获取数据库状态失败: ' + (err.message || '网络错误')))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refreshStatus().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const getPayload = (): DatabaseConnectionPayload => {
|
||||
if (mode === 'dsn') {
|
||||
return { type: 'postgres', dsn: formData.dsn?.trim() || '' }
|
||||
}
|
||||
return {
|
||||
type: 'postgres',
|
||||
host: formData.host?.trim() || '',
|
||||
port: Number(formData.port) || 5432,
|
||||
user: formData.user?.trim() || '',
|
||||
password: formData.password || '',
|
||||
dbname: formData.dbname?.trim() || 'mmtl',
|
||||
sslmode: formData.sslmode || 'disable',
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
const payload = getPayload()
|
||||
if (mode === 'form' && (!payload.host || !payload.user)) {
|
||||
toast.error('请填写 PostgreSQL 主机和用户名')
|
||||
return
|
||||
}
|
||||
if (mode === 'dsn' && !payload.dsn) {
|
||||
toast.error('请填写 PostgreSQL DSN')
|
||||
return
|
||||
}
|
||||
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
try {
|
||||
const res = await adminAPI.testDatabaseConnection(payload)
|
||||
setTestResult(res)
|
||||
if (res.success) {
|
||||
toast.success(`连接成功!延迟: ${res.latency_ms}ms`)
|
||||
} else {
|
||||
toast.error(res.error || '连接失败')
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.error || err.message || '测试连接异常'
|
||||
setTestResult({ success: false, error: errorMsg })
|
||||
toast.error(errorMsg)
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMigrate = async () => {
|
||||
const payload = getPayload()
|
||||
if (status?.type === 'postgres') {
|
||||
const ok = await confirmAction({
|
||||
title: '覆盖/同步确认',
|
||||
message: '当前已经处于 PostgreSQL 模式,继续迁移将覆盖/合并目标库的数据,确定继续吗?',
|
||||
})
|
||||
if (!ok) return
|
||||
} else {
|
||||
const ok = await confirmAction({
|
||||
title: '开始数据库迁移',
|
||||
message: '即将把当前 SQLite 数据库中的所有媒体、用户、播放记录、设置等全量迁移到目标 PostgreSQL 数据库。确定开始吗?',
|
||||
})
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
setMigrating(true)
|
||||
setMigrationResult(null)
|
||||
try {
|
||||
const res = await adminAPI.migrateDatabase(payload)
|
||||
setMigrationResult(res)
|
||||
if (res.success) {
|
||||
toast.success(`数据迁移完成!共迁移 ${res.total_rows} 条记录`)
|
||||
} else {
|
||||
toast.error(res.error || '数据迁移失败')
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.error || err.message || '迁移发生错误'
|
||||
setMigrationResult({ success: false, total_rows: 0, duration_ms: 0, error: errorMsg })
|
||||
toast.error(errorMsg)
|
||||
} finally {
|
||||
setMigrating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveAndSwitch = async () => {
|
||||
const payload = getPayload()
|
||||
const ok = await confirmAction({
|
||||
title: '切换数据库',
|
||||
message: '保存后系统配置将更新为使用 PostgreSQL。需要重启 MMTL 服务使新数据库生效。确定保存吗?',
|
||||
})
|
||||
if (!ok) return
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await adminAPI.saveDatabaseConfig(payload)
|
||||
toast.success(res.message || '数据库配置已保存,请重启服务生效')
|
||||
refreshStatus()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.error || err.message || '保存配置失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 头部标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Database className="h-6 w-6 text-brand-500" />
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">数据库设置与迁移</h2>
|
||||
<p className="text-xs text-ink-50">
|
||||
管理系统底层数据库,支持在 SQLite(本地嵌入式)与 PostgreSQL(高性能关系库)之间平滑切换与数据迁移
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={refreshStatus}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-sand-200/50 px-3 py-1.5 text-xs text-ink-100 hover:bg-sand-200 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
|
||||
刷新状态
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 当前数据库状态卡片 */}
|
||||
<div className="glass-panel p-5 space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server size={18} className="text-brand-500" />
|
||||
<span className="font-medium text-sm text-ink-600">当前运行引擎</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={
|
||||
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ' +
|
||||
(status?.type === 'postgres'
|
||||
? 'bg-blue-500/10 text-blue-400 border border-blue-500/20'
|
||||
: 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20')
|
||||
}
|
||||
>
|
||||
<Zap size={12} />
|
||||
{status?.type === 'postgres' ? 'PostgreSQL' : 'SQLite (WAL 优化)'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
|
||||
<div className="space-y-1 rounded-lg bg-sand-200/30 p-3">
|
||||
<p className="text-sand-500">存储位置 / 连接</p>
|
||||
<p className="font-mono text-ink-600 break-all">
|
||||
{status?.type === 'postgres'
|
||||
? status.dsn || '配置的 PostgreSQL 实例'
|
||||
: status?.db_path || './data/mmtl.db'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1 rounded-lg bg-sand-200/30 p-3">
|
||||
<p className="text-sand-500">连接池活跃 / 最大</p>
|
||||
<p className="font-mono text-ink-600">
|
||||
活跃: {status?.in_use ?? 0} · 空闲: {status?.idle ?? 0} · 上限:{' '}
|
||||
{status?.max_open_conns ?? 16}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1 rounded-lg bg-sand-200/30 p-3">
|
||||
<p className="text-sand-500">核心表记录概览</p>
|
||||
<p className="text-ink-600">
|
||||
媒体: {status?.table_counts?.media ?? 0} · 用户: {status?.table_counts?.users ?? 0} ·
|
||||
播放记录: {status?.table_counts?.playback_histories ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 配置 PostgreSQL */}
|
||||
<div className="glass-panel p-5 space-y-5">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive size={18} className="text-brand-500" />
|
||||
<span className="font-medium text-sm text-ink-600">配置目标 PostgreSQL</span>
|
||||
</div>
|
||||
<div className="flex rounded-lg bg-sand-200/40 p-0.5 text-xs">
|
||||
<button
|
||||
onClick={() => setMode('form')}
|
||||
className={
|
||||
'rounded-md px-3 py-1 transition ' +
|
||||
(mode === 'form'
|
||||
? 'bg-brand-500 text-white font-medium shadow-sm'
|
||||
: 'text-sand-500 hover:text-ink-600')
|
||||
}
|
||||
>
|
||||
分段表单
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('dsn')}
|
||||
className={
|
||||
'rounded-md px-3 py-1 transition ' +
|
||||
(mode === 'dsn'
|
||||
? 'bg-brand-500 text-white font-medium shadow-sm'
|
||||
: 'text-sand-500 hover:text-ink-600')
|
||||
}
|
||||
>
|
||||
完整 DSN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'form' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-sand-500">主机地址 (Host)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.host || ''}
|
||||
onChange={(e) => setFormData({ ...formData, host: e.target.value })}
|
||||
placeholder="例如 127.0.0.1 或 postgres"
|
||||
className="w-full rounded-lg border border-gray-200 bg-sand-200/40 px-3 py-2 text-ink-600 placeholder:text-gray-400 focus:border-brand-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-sand-500">端口 (Port)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.port || 5432}
|
||||
onChange={(e) => setFormData({ ...formData, port: Number(e.target.value) })}
|
||||
placeholder="5432"
|
||||
className="w-full rounded-lg border border-gray-200 bg-sand-200/40 px-3 py-2 text-ink-600 placeholder:text-gray-400 focus:border-brand-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-sand-500">数据库名 (Database)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.dbname || ''}
|
||||
onChange={(e) => setFormData({ ...formData, dbname: e.target.value })}
|
||||
placeholder="mmtl"
|
||||
className="w-full rounded-lg border border-gray-200 bg-sand-200/40 px-3 py-2 text-ink-600 placeholder:text-gray-400 focus:border-brand-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-sand-500">用户名 (User)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.user || ''}
|
||||
onChange={(e) => setFormData({ ...formData, user: e.target.value })}
|
||||
placeholder="postgres"
|
||||
className="w-full rounded-lg border border-gray-200 bg-sand-200/40 px-3 py-2 text-ink-600 placeholder:text-gray-400 focus:border-brand-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-sand-500">密码 (Password)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password || ''}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder="••••••••"
|
||||
className="w-full rounded-lg border border-gray-200 bg-sand-200/40 px-3 py-2 text-ink-600 placeholder:text-gray-400 focus:border-brand-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-sand-500">SSL 模式 (SSL Mode)</label>
|
||||
<select
|
||||
value={formData.sslmode || 'disable'}
|
||||
onChange={(e) => setFormData({ ...formData, sslmode: e.target.value })}
|
||||
className="w-full rounded-lg border border-gray-200 bg-sand-200/40 px-3 py-2 text-ink-600 focus:border-brand-500 focus:outline-none"
|
||||
>
|
||||
<option value="disable">disable (关闭 SSL)</option>
|
||||
<option value="require">require (强制 SSL)</option>
|
||||
<option value="verify-ca">verify-ca (验证 CA)</option>
|
||||
<option value="verify-full">verify-full (严格验证证书与主机名)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<label className="text-xs font-medium text-sand-500">
|
||||
PostgreSQL DSN 字符串 (URL 格式)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.dsn || ''}
|
||||
onChange={(e) => setFormData({ ...formData, dsn: e.target.value })}
|
||||
placeholder="postgres://user:password@127.0.0.1:5432/mmtl?sslmode=disable"
|
||||
className="w-full font-mono text-xs rounded-lg border border-gray-200 bg-sand-200/40 px-3 py-2 text-ink-600 placeholder:text-gray-400 focus:border-brand-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 测试结果卡片 */}
|
||||
{testResult && (
|
||||
<div
|
||||
className={
|
||||
'flex items-start gap-2.5 rounded-lg p-3.5 text-xs ' +
|
||||
(testResult.success
|
||||
? 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-400'
|
||||
: 'bg-rose-500/10 border border-rose-500/20 text-rose-400')
|
||||
}
|
||||
>
|
||||
{testResult.success ? (
|
||||
<CheckCircle2 size={16} className="mt-0.5 shrink-0" />
|
||||
) : (
|
||||
<HelpCircle size={16} className="mt-0.5 shrink-0" />
|
||||
)}
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-semibold">
|
||||
{testResult.success ? `连接测试通过 (${testResult.latency_ms} ms)` : '连接测试未通过'}
|
||||
</p>
|
||||
{testResult.version && <p className="text-ink-100">{testResult.version}</p>}
|
||||
{testResult.error && <p className="text-rose-300 font-mono">{testResult.error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 迁移结果卡片 */}
|
||||
{migrationResult && (
|
||||
<div
|
||||
className={
|
||||
'rounded-lg p-3.5 text-xs space-y-2 ' +
|
||||
(migrationResult.success
|
||||
? 'bg-blue-500/10 border border-blue-500/20 text-blue-400'
|
||||
: 'bg-rose-500/10 border border-rose-500/20 text-rose-400')
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<ShieldCheck size={16} />
|
||||
<span>{migrationResult.message || (migrationResult.success ? '迁移完成' : '迁移失败')}</span>
|
||||
{migrationResult.success && (
|
||||
<span className="text-sand-500 text-[11px]">
|
||||
(耗时: {migrationResult.duration_ms} ms)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{migrationResult.table_rows && Object.keys(migrationResult.table_rows).length > 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 pt-1 font-mono text-[11px] text-ink-100">
|
||||
{Object.entries(migrationResult.table_rows).map(([tbl, count]) => (
|
||||
<div key={tbl} className="rounded bg-sand-200/40 px-2 py-1 flex justify-between">
|
||||
<span>{tbl}:</span>
|
||||
<span className="font-bold text-brand-400">{count} 条</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{migrationResult.error && (
|
||||
<p className="text-rose-300 font-mono">{migrationResult.error}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮区 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pt-2 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testing || migrating || saving}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-sand-200/60 px-4 py-2 text-xs font-medium text-ink-600 hover:bg-sand-200 disabled:opacity-50"
|
||||
>
|
||||
{testing ? <Loader2 size={14} className="animate-spin" /> : <Activity size={14} />}
|
||||
测试连接
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMigrate}
|
||||
disabled={testing || migrating || saving}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-primary-500/30 bg-primary-500/10 px-4 py-2 text-xs font-medium text-brand-400 hover:bg-primary-500/20 disabled:opacity-50"
|
||||
>
|
||||
{migrating ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<ArrowRightLeft size={14} />
|
||||
)}
|
||||
一键数据迁移到 PostgreSQL
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveAndSwitch}
|
||||
disabled={testing || migrating || saving}
|
||||
className="neon-button text-xs disabled:opacity-50"
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
保存并切换
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+15
-45
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { libraryAPI, type LibraryWithPreview } from '../api/library'
|
||||
import { playbackAPI, type HistoryItem } from '../api/playback'
|
||||
import type { Library, Media } from '../types'
|
||||
import { groupSeries, type SeriesCard } from '../utils/groupSeries'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
import {
|
||||
ContinueWatchingSection,
|
||||
HomeCarouselSection,
|
||||
@@ -29,56 +29,26 @@ export function HomePage() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [libs, hist] = await Promise.all([
|
||||
libraryAPI.list().then((rows) => asArray<Library>(rows)).catch(() => [] as Library[]),
|
||||
playbackAPI.recentHistory().then((rows) => asArray<HistoryItem>(rows)).catch(() => [] as HistoryItem[]),
|
||||
libraryAPI
|
||||
.list({ withPreview: true, previewLimit: 20 })
|
||||
.then((rows) => asArray<LibraryWithPreview>(rows))
|
||||
.catch(() => [] as LibraryWithPreview[]),
|
||||
playbackAPI
|
||||
.recentHistory()
|
||||
.then((rows) => asArray<HistoryItem>(rows))
|
||||
.catch(() => [] as HistoryItem[]),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
setLibraries(libs)
|
||||
setHistory(hist.filter((h) => h && !h.completed && !!h.media))
|
||||
|
||||
// Fetch media items for all libraries in parallel
|
||||
const isSeriesType = (type?: string) => type === 'tv' || type === 'anime' || type === 'variety'
|
||||
const results = await Promise.allSettled(
|
||||
libs.map(async (lib) => {
|
||||
// 剧集类媒体库(tv/anime/variety):后端 /series 已按剧聚合,
|
||||
// 首页若用 episode 级 /media 的前 30 行再 groupSeries,同一部剧的
|
||||
// 多集会折叠成 1 张卡,导致整行只显示 1 个条目。
|
||||
// 改用 /series 分页拉取全部聚合后的剧集卡片。
|
||||
if (isSeriesType(lib.type)) {
|
||||
const cards: SeriesCard[] = []
|
||||
let total = 0
|
||||
const pageSize = 200
|
||||
for (let page = 1; page <= 10; page++) {
|
||||
const data = await libraryAPI.listSeries(lib.id, page, pageSize)
|
||||
const pageItems = asArray<SeriesCard>(data?.items)
|
||||
cards.push(...pageItems)
|
||||
total = data?.total ?? cards.length
|
||||
if (cards.length >= total || pageItems.length < pageSize) break
|
||||
}
|
||||
return { id: lib.id, cards, items: [], total }
|
||||
}
|
||||
const page = await libraryAPI.listMedia(lib.id, 1, 30)
|
||||
const items = asArray<Media>(page?.items)
|
||||
const cards = groupSeries(items)
|
||||
return {
|
||||
id: lib.id,
|
||||
cards,
|
||||
items,
|
||||
total: page?.total ?? items.length,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
if (cancelled) return
|
||||
const mapData: Record<string, { cards: SeriesCard[]; items: Media[]; total: number }> = {}
|
||||
for (const res of results) {
|
||||
if (res.status === 'fulfilled' && res.value) {
|
||||
mapData[res.value.id] = {
|
||||
cards: res.value.cards,
|
||||
items: res.value.items,
|
||||
total: res.value.total,
|
||||
}
|
||||
for (const lib of libs) {
|
||||
mapData[lib.id] = {
|
||||
cards: lib.cards ?? [],
|
||||
items: [],
|
||||
total: lib.total ?? 0,
|
||||
}
|
||||
}
|
||||
setLibraryData(mapData)
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
LibrariesEmptyState,
|
||||
LibrariesHeader,
|
||||
} from './LibrariesPageSections'
|
||||
import { isSeriesLibraryType, latestLibraryCards, type LibraryPreview } from './librariesPageModel'
|
||||
import type { LibraryPreview } from './librariesPageModel'
|
||||
|
||||
export function LibrariesPage() {
|
||||
const [previews, setPreviews] = useState<LibraryPreview[]>([])
|
||||
@@ -20,24 +20,15 @@ export function LibrariesPage() {
|
||||
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)
|
||||
const libs = await libraryAPI.list({ withPreview: true })
|
||||
setPreviews(
|
||||
libs.map((library) => ({
|
||||
library,
|
||||
items: [],
|
||||
total: library.total ?? 0,
|
||||
cards: library.cards ?? [],
|
||||
})),
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useLocation, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
import { historyAPI } from '../api/history'
|
||||
import type { Media } from '../types'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
import {
|
||||
sortMediaList,
|
||||
sortSeriesList,
|
||||
type SortField,
|
||||
type SortOrder,
|
||||
} from '../utils/mediaSort'
|
||||
import { LibraryPageDialogs } from './LibraryPageDialogs'
|
||||
import { LibraryPageHeader } from './LibraryPageHeader'
|
||||
import { LibraryMediaSections } from './LibraryMediaSections'
|
||||
@@ -25,6 +32,53 @@ export function LibraryPage() {
|
||||
const [seriesMetadataEditOpen, setSeriesMetadataEditOpen] = useState(false)
|
||||
const [manualMovie, setManualMovie] = useState<Media | null>(null)
|
||||
|
||||
// 排序状态(支持按库记忆)
|
||||
const [sortField, setSortField] = useState<SortField>(() => {
|
||||
const saved = localStorage.getItem(`mmtl_lib_sort_field_${id}`) || localStorage.getItem('mmtl_lib_sort_field')
|
||||
return (saved as SortField) || 'title'
|
||||
})
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>(() => {
|
||||
const saved = localStorage.getItem(`mmtl_lib_sort_order_${id}`) || localStorage.getItem('mmtl_lib_sort_order')
|
||||
return (saved as SortOrder) || 'asc'
|
||||
})
|
||||
const [randomSeed, setRandomSeed] = useState(() => Date.now())
|
||||
const [historyMap, setHistoryMap] = useState<Map<string, string>>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
historyAPI
|
||||
.list(1000)
|
||||
.then((historyItems) => {
|
||||
const map = new Map<string, string>()
|
||||
for (const item of historyItems ?? []) {
|
||||
if (item.media_id && item.watched_at) {
|
||||
if (!map.has(item.media_id) || new Date(item.watched_at) > new Date(map.get(item.media_id)!)) {
|
||||
map.set(item.media_id, item.watched_at)
|
||||
}
|
||||
}
|
||||
}
|
||||
setHistoryMap(map)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleSortChange = (field: SortField, order: SortOrder) => {
|
||||
setSortField(field)
|
||||
setSortOrder(order)
|
||||
if (id) {
|
||||
localStorage.setItem(`mmtl_lib_sort_field_${id}`, field)
|
||||
localStorage.setItem(`mmtl_lib_sort_order_${id}`, order)
|
||||
}
|
||||
localStorage.setItem('mmtl_lib_sort_field', field)
|
||||
localStorage.setItem('mmtl_lib_sort_order', order)
|
||||
if (field === 'random') {
|
||||
setRandomSeed(Date.now())
|
||||
}
|
||||
}
|
||||
|
||||
const handleReshuffle = () => {
|
||||
setRandomSeed(Date.now())
|
||||
}
|
||||
|
||||
// 剧集模式:选中某个剧集后展开详情
|
||||
const [selectedSeries, setSelectedSeries] = useState<SeriesCard | null>(null)
|
||||
const [selectedSeason, setSelectedSeason] = useState<number | null>(null)
|
||||
@@ -43,6 +97,14 @@ export function LibraryPage() {
|
||||
reloadCurrentLibrary,
|
||||
} = useLibraryData(id, selectedSeries)
|
||||
|
||||
const sortedItems = useMemo(() => {
|
||||
return sortMediaList(items, sortField, sortOrder, randomSeed, historyMap)
|
||||
}, [items, sortField, sortOrder, randomSeed, historyMap])
|
||||
|
||||
const sortedSeriesCards = useMemo(() => {
|
||||
return sortSeriesList(seriesCards, sortField, sortOrder, randomSeed, historyMap)
|
||||
}, [seriesCards, sortField, sortOrder, randomSeed, historyMap])
|
||||
|
||||
const {
|
||||
scanning,
|
||||
scanProgress,
|
||||
@@ -66,7 +128,7 @@ export function LibraryPage() {
|
||||
isSeriesLibrary,
|
||||
isSeries,
|
||||
loading,
|
||||
seriesCards,
|
||||
seriesCards: sortedSeriesCards,
|
||||
searchParams,
|
||||
setSearchParams,
|
||||
selectedSeries,
|
||||
@@ -116,7 +178,7 @@ export function LibraryPage() {
|
||||
{!selectedSeries && (
|
||||
<LibraryPageHeader
|
||||
library={library}
|
||||
itemCount={isSeries ? seriesCards.length : total}
|
||||
itemCount={isSeries ? sortedSeriesCards.length : total}
|
||||
loadingAllText={loadingAllText}
|
||||
scanProgress={scanProgress}
|
||||
isAdmin={role === 'admin'}
|
||||
@@ -124,6 +186,10 @@ export function LibraryPage() {
|
||||
scanning={scanning}
|
||||
scraping={scraping}
|
||||
repairing={repairing}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={handleSortChange}
|
||||
onReshuffle={handleReshuffle}
|
||||
onScrapeEpisodeArtworkChange={setScrapeEpisodeArtwork}
|
||||
onScan={handleScan}
|
||||
onScrape={() => setScrapeDialogOpen(true)}
|
||||
@@ -133,8 +199,8 @@ export function LibraryPage() {
|
||||
|
||||
<LibraryMediaSections
|
||||
isSeries={isSeries}
|
||||
items={items}
|
||||
seriesCards={seriesCards}
|
||||
items={sortedItems}
|
||||
seriesCards={sortedSeriesCards}
|
||||
selectedSeries={selectedSeries}
|
||||
loading={loading}
|
||||
movieActions={movieActions}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Library } from '../types'
|
||||
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
||||
import { MediaSortDropdown } from '../components/MediaSortDropdown'
|
||||
import type { SortField, SortOrder } from '../utils/mediaSort'
|
||||
import { libraryDisplayPath } from './libraryDisplayModel'
|
||||
|
||||
type LibraryPageHeaderProps = {
|
||||
@@ -12,6 +14,10 @@ type LibraryPageHeaderProps = {
|
||||
scanning: boolean
|
||||
scraping: boolean
|
||||
repairing: boolean
|
||||
sortField: SortField
|
||||
sortOrder: SortOrder
|
||||
onSortChange: (field: SortField, order: SortOrder) => void
|
||||
onReshuffle: () => void
|
||||
onScrapeEpisodeArtworkChange: (checked: boolean) => void
|
||||
onScan: () => void
|
||||
onScrape: () => void
|
||||
@@ -28,6 +34,10 @@ export function LibraryPageHeader({
|
||||
scanning,
|
||||
scraping,
|
||||
repairing,
|
||||
sortField,
|
||||
sortOrder,
|
||||
onSortChange,
|
||||
onReshuffle,
|
||||
onScrapeEpisodeArtworkChange,
|
||||
onScan,
|
||||
onScrape,
|
||||
@@ -46,30 +56,38 @@ export function LibraryPageHeader({
|
||||
{loadingAllText && <p className="mt-1 text-xs text-sand-500">{loadingAllText}</p>}
|
||||
{scanProgress && <p className="mt-1 text-xs text-brand-500">{scanProgress}</p>}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EpisodeArtworkToggle
|
||||
checked={scrapeEpisodeArtwork}
|
||||
onChange={onScrapeEpisodeArtworkChange}
|
||||
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
|
||||
className="h-10"
|
||||
/>
|
||||
<button onClick={onScan} disabled={scanning} className="btn-outline">
|
||||
{scanning ? '扫描中…' : '扫描媒体库'}
|
||||
</button>
|
||||
<button onClick={onScrape} disabled={scraping} className="btn-outline" title="对整个媒体库执行刮削元数据">
|
||||
{scraping ? '刮削中…' : '整库刮削元数据'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onRepairRescrape}
|
||||
disabled={repairing}
|
||||
className="btn-outline"
|
||||
title="回填本库占位符外部 ID 并重刮,修正空 ID / 拆集问题"
|
||||
>
|
||||
{repairing ? '修复中…' : '修复+重刮整库'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<MediaSortDropdown
|
||||
value={sortField}
|
||||
order={sortOrder}
|
||||
onChange={onSortChange}
|
||||
onReshuffle={onReshuffle}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<EpisodeArtworkToggle
|
||||
checked={scrapeEpisodeArtwork}
|
||||
onChange={onScrapeEpisodeArtworkChange}
|
||||
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
|
||||
className="h-9"
|
||||
/>
|
||||
<button onClick={onScan} disabled={scanning} className="btn-outline">
|
||||
{scanning ? '扫描中…' : '扫描媒体库'}
|
||||
</button>
|
||||
<button onClick={onScrape} disabled={scraping} className="btn-outline" title="对整个媒体库执行刮削元数据">
|
||||
{scraping ? '刮削中…' : '整库刮削元数据'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onRepairRescrape}
|
||||
disabled={repairing}
|
||||
className="btn-outline"
|
||||
title="回填本库占位符外部 ID 并重刮,修正空 ID / 拆集问题"
|
||||
>
|
||||
{repairing ? '修复中…' : '修复+重刮整库'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,40 +62,53 @@ export function LibrarySeriesEpisodes({
|
||||
<h3 className="mb-3 font-display text-lg font-semibold text-ink-600">
|
||||
{displaySeason === 0 ? '特别篇' : `第 ${displaySeason} 季`}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{visibleEpisodes.map((ep) => (
|
||||
<div
|
||||
key={ep.id}
|
||||
className="group flex items-center gap-3 rounded-xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:border-brand-300 hover:shadow-card-hover"
|
||||
>
|
||||
<Link to={`/play/${ep.id}`} state={{ from: playbackFrom }} className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-brand-50 text-brand-600 font-semibold text-sm">
|
||||
{ep.backdrop_url || ep.poster_url ? (
|
||||
<img
|
||||
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
ep.episode_num || '—'
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-ink-600">
|
||||
{episodeDisplayTitle(ep, visibleEpisodes)}
|
||||
</p>
|
||||
<p className="text-xs text-sand-500">
|
||||
{ep.duration_sec > 0
|
||||
? `${Math.floor(ep.duration_sec / 60)} 分钟`
|
||||
: formatSize(ep.size_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
<Play size={14} className="shrink-0 text-gray-500 opacity-0 transition-opacity group-hover:opacity-100 group-hover:text-brand-500" />
|
||||
</Link>
|
||||
<ExternalPlayerButton mediaId={ep.id} label="外部" compact />
|
||||
</div>
|
||||
))}
|
||||
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
{visibleEpisodes.map((ep) => {
|
||||
const displayTitle = episodeDisplayTitle(ep, visibleEpisodes)
|
||||
return (
|
||||
<div
|
||||
key={ep.id}
|
||||
className="group flex items-center justify-between gap-3 rounded-xl border border-sand-200 bg-white p-2.5 shadow-card transition-all hover:border-brand-300 hover:shadow-card-hover"
|
||||
>
|
||||
<Link to={`/play/${ep.id}`} state={{ from: playbackFrom }} className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div
|
||||
className="relative flex h-11 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-brand-50/70 border border-sand-200/60"
|
||||
title={displayTitle}
|
||||
>
|
||||
{ep.backdrop_url || ep.poster_url ? (
|
||||
<img
|
||||
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-brand-600 font-bold text-sm">{ep.episode_num || '—'}</span>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/25 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<Play size={15} className="fill-white text-white drop-shadow-sm" />
|
||||
</div>
|
||||
{ep.episode_num > 0 && (
|
||||
<span className="absolute bottom-0 right-0 rounded-tl bg-black/75 px-1 py-0.5 text-[9px] font-bold leading-none text-white backdrop-blur-[2px]">
|
||||
{ep.episode_num}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-ink-600 transition-colors group-hover:text-brand-600" title={displayTitle}>
|
||||
{displayTitle}
|
||||
</p>
|
||||
<p className="text-xs text-sand-500 whitespace-nowrap">
|
||||
{ep.duration_sec > 0
|
||||
? `${Math.floor(ep.duration_sec / 60)} 分钟`
|
||||
: formatSize(ep.size_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<ExternalPlayerButton mediaId={ep.id} label="外部" compact />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -103,17 +116,29 @@ export function LibrarySeriesEpisodes({
|
||||
}
|
||||
|
||||
function episodeDisplayTitle(ep: Media, siblings: Media[]): string {
|
||||
const title = ep.episode_title?.trim()
|
||||
if (title && !looksLikeSeriesTitle(ep, title, siblings)) {
|
||||
return title
|
||||
let mainTitle = ''
|
||||
const epTitle = ep.episode_title?.trim()
|
||||
if (epTitle && !looksLikeSeriesTitle(ep, epTitle, siblings)) {
|
||||
mainTitle = epTitle
|
||||
} else {
|
||||
const mediaTitle = ep.title?.trim()
|
||||
if (mediaTitle && !looksLikeSeriesTitle(ep, mediaTitle, siblings)) {
|
||||
mainTitle = mediaTitle
|
||||
}
|
||||
}
|
||||
|
||||
const mediaTitle = ep.title?.trim()
|
||||
if (mediaTitle && !looksLikeSeriesTitle(ep, mediaTitle, siblings)) {
|
||||
return mediaTitle
|
||||
if (ep.episode_num > 0) {
|
||||
if (!mainTitle) {
|
||||
return `第 ${ep.episode_num} 集`
|
||||
}
|
||||
const prefixRegex = new RegExp(`^(第\\s*0*${ep.episode_num}\\s*集|ep?\\.?\\s*0*${ep.episode_num}\\b)`, 'i')
|
||||
if (prefixRegex.test(mainTitle)) {
|
||||
return mainTitle
|
||||
}
|
||||
return `第 ${ep.episode_num} 集 · ${mainTitle}`
|
||||
}
|
||||
|
||||
return ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : mediaTitle || title || '未命名'
|
||||
return mainTitle || '未命名'
|
||||
}
|
||||
|
||||
function looksLikeSeriesTitle(ep: Media, title: string, siblings: Media[]): boolean {
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import type Hls from 'hls.js'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { mediaAPI } from '../api/library'
|
||||
import { mediaAPI, libraryAPI } from '../api/library'
|
||||
import { api, hlsURL, streamURL } from '../api/client'
|
||||
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'
|
||||
import type { Media } from '../types'
|
||||
import { getSeriesKey, isEpisodeLike } from '../utils/groupSeries'
|
||||
import { getSeriesKey, isEpisodeLike, seriesTitleFromPath } from '../utils/groupSeries'
|
||||
import { pickPlayerMode, needsTranscodeForBrowser, type PlayerMode } from './playerPageModel'
|
||||
import { PlayerTopBar } from './PlayerTopBar'
|
||||
import { PlayerVideoStage } from './PlayerVideoStage'
|
||||
import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
|
||||
import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel'
|
||||
|
||||
// Fullscreen, dark-themed video page.
|
||||
//
|
||||
@@ -80,6 +81,10 @@ export function PlayerPage() {
|
||||
const [danmakuFontSize, setDanmakuFontSize] = useState(24)
|
||||
const [danmakuArea, setDanmakuArea] = useState(1)
|
||||
|
||||
// 选集 / 播放列表状态
|
||||
const [playlistEpisodes, setPlaylistEpisodes] = useState<Media[]>([])
|
||||
const [playlistOpen, setPlaylistOpen] = useState(false)
|
||||
|
||||
const teardownHls = useCallback((mediaId?: string, stopServer = false) => {
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy()
|
||||
@@ -272,14 +277,160 @@ export function PlayerPage() {
|
||||
}
|
||||
}, [media])
|
||||
|
||||
// ESC = back.
|
||||
// 加载剧集/播放列表
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
let canceled = false
|
||||
mediaAPI
|
||||
.getEpisodes(id)
|
||||
.then((res) => {
|
||||
if (canceled) return
|
||||
setPlaylistEpisodes(res.items ?? [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (canceled) return
|
||||
if (media && (media.display_library_id || media.library_id)) {
|
||||
const libId = media.display_library_id || media.library_id
|
||||
const seriesKey = getSeriesKey(media)
|
||||
if (seriesKey) {
|
||||
libraryAPI
|
||||
.listSeriesEpisodes(libId, seriesKey)
|
||||
.then((res) => {
|
||||
if (!canceled) setPlaylistEpisodes(res.items ?? [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (!canceled) setPlaylistEpisodes([])
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
setPlaylistEpisodes([])
|
||||
})
|
||||
return () => {
|
||||
canceled = true
|
||||
}
|
||||
}, [id, media])
|
||||
|
||||
const currentEpisodeIndex = useMemo(() => {
|
||||
if (!media || playlistEpisodes.length === 0) return -1
|
||||
return playlistEpisodes.findIndex((e) => e.id === media.id)
|
||||
}, [media, playlistEpisodes])
|
||||
|
||||
const prevEpisode = useMemo(() => {
|
||||
if (currentEpisodeIndex > 0) {
|
||||
return playlistEpisodes[currentEpisodeIndex - 1]
|
||||
}
|
||||
return null
|
||||
}, [currentEpisodeIndex, playlistEpisodes])
|
||||
|
||||
const nextEpisode = useMemo(() => {
|
||||
if (currentEpisodeIndex >= 0 && currentEpisodeIndex < playlistEpisodes.length - 1) {
|
||||
return playlistEpisodes[currentEpisodeIndex + 1]
|
||||
}
|
||||
return null
|
||||
}, [currentEpisodeIndex, playlistEpisodes])
|
||||
|
||||
const prevEpisodeTitle = useMemo(() => {
|
||||
return prevEpisode ? formatEpisodeDisplay(prevEpisode, playlistEpisodes) : ''
|
||||
}, [prevEpisode, playlistEpisodes])
|
||||
|
||||
const nextEpisodeTitle = useMemo(() => {
|
||||
return nextEpisode ? formatEpisodeDisplay(nextEpisode, playlistEpisodes) : ''
|
||||
}, [nextEpisode, playlistEpisodes])
|
||||
|
||||
const playEpisode = useCallback(
|
||||
(target: Media) => {
|
||||
navigate(
|
||||
{
|
||||
pathname: `/play/${target.id}`,
|
||||
search: location.search,
|
||||
},
|
||||
{ state: location.state },
|
||||
)
|
||||
},
|
||||
[navigate, location.search, location.state],
|
||||
)
|
||||
|
||||
const handlePrevEpisode = useCallback(() => {
|
||||
if (prevEpisode) {
|
||||
playEpisode(prevEpisode)
|
||||
}
|
||||
}, [prevEpisode, playEpisode])
|
||||
|
||||
const handleNextEpisode = useCallback(() => {
|
||||
if (nextEpisode) {
|
||||
playEpisode(nextEpisode)
|
||||
}
|
||||
}, [nextEpisode, playEpisode])
|
||||
|
||||
const togglePlaylistOpen = useCallback(() => {
|
||||
setPlaylistOpen((prev) => {
|
||||
const next = !prev
|
||||
if (next) setDanmakuOpen(false)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleDanmakuOpen = useCallback(() => {
|
||||
setDanmakuOpen((prev) => {
|
||||
const next = !prev
|
||||
if (next) setPlaylistOpen(false)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 视频播放结束时自动播放下一集
|
||||
useEffect(() => {
|
||||
if (!ref.current || !nextEpisode) return
|
||||
const video = ref.current
|
||||
const onEnded = () => {
|
||||
toast.success(`正在播放下一集:${nextEpisodeTitle || '下一集'}`)
|
||||
playEpisode(nextEpisode)
|
||||
}
|
||||
video.addEventListener('ended', onEnded)
|
||||
return () => {
|
||||
video.removeEventListener('ended', onEnded)
|
||||
}
|
||||
}, [nextEpisode, nextEpisodeTitle, playEpisode])
|
||||
|
||||
// ESC = back 或关闭浮层,[ / ] 或 Shift+P / Shift+N 切换上一集/下一集
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') goBack()
|
||||
const target = e.target as HTMLElement | null
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
if (playlistOpen) {
|
||||
setPlaylistOpen(false)
|
||||
return
|
||||
}
|
||||
if (danmakuOpen) {
|
||||
setDanmakuOpen(false)
|
||||
return
|
||||
}
|
||||
goBack()
|
||||
} else if (e.key === '[' || (e.shiftKey && e.key.toLowerCase() === 'p')) {
|
||||
if (prevEpisode) {
|
||||
e.preventDefault()
|
||||
handlePrevEpisode()
|
||||
}
|
||||
} else if (e.key === ']' || (e.shiftKey && e.key.toLowerCase() === 'n')) {
|
||||
if (nextEpisode) {
|
||||
e.preventDefault()
|
||||
handleNextEpisode()
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [goBack])
|
||||
}, [goBack, prevEpisode, nextEpisode, handlePrevEpisode, handleNextEpisode, playlistOpen, danmakuOpen])
|
||||
|
||||
const toggleMode = useCallback(() => {
|
||||
const next = mode === 'hls' ? 'direct' : 'hls'
|
||||
@@ -344,9 +495,27 @@ export function PlayerPage() {
|
||||
danmakuSearch={danmakuSearch}
|
||||
danmakuEpisodeId={danmakuEpisodeId}
|
||||
danmakuOpen={danmakuOpen}
|
||||
onToggleDanmaku={() => setDanmakuOpen((v) => !v)}
|
||||
onToggleDanmaku={toggleDanmakuOpen}
|
||||
onDanmakuLoaded={danmakuLoaded}
|
||||
onDanmakuCandidates={danmakuGotCandidates}
|
||||
hasPrevEpisode={Boolean(prevEpisode)}
|
||||
hasNextEpisode={Boolean(nextEpisode)}
|
||||
onPrevEpisode={handlePrevEpisode}
|
||||
onNextEpisode={handleNextEpisode}
|
||||
prevEpisodeTitle={prevEpisodeTitle}
|
||||
nextEpisodeTitle={nextEpisodeTitle}
|
||||
playlistOpen={playlistOpen}
|
||||
hasPlaylist={playlistEpisodes.length > 0}
|
||||
onTogglePlaylist={togglePlaylistOpen}
|
||||
playlistPanel={
|
||||
<PlayerPlaylistPanel
|
||||
open={playlistOpen}
|
||||
onClose={() => setPlaylistOpen(false)}
|
||||
currentMediaId={media?.id ?? ''}
|
||||
episodes={playlistEpisodes}
|
||||
onSelectEpisode={playEpisode}
|
||||
/>
|
||||
}
|
||||
danmakuPanel={
|
||||
<PlayerDanmakuPanel
|
||||
open={danmakuOpen}
|
||||
@@ -373,3 +542,41 @@ export function PlayerPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatEpisodeDisplay(ep: Media, siblings: Media[]): string {
|
||||
const title = ep.episode_title?.trim()
|
||||
if (title && !looksLikeSeriesTitle(ep, title, siblings)) {
|
||||
return title
|
||||
}
|
||||
|
||||
const mediaTitle = ep.title?.trim()
|
||||
if (mediaTitle && !looksLikeSeriesTitle(ep, mediaTitle, siblings)) {
|
||||
return mediaTitle
|
||||
}
|
||||
|
||||
return ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : mediaTitle || title || '未命名'
|
||||
}
|
||||
|
||||
function looksLikeSeriesTitle(ep: Media, title: string, siblings: Media[]): boolean {
|
||||
const normalized = normalizeEpisodeTitle(title)
|
||||
if (!normalized) return true
|
||||
if (ep.original_name && normalizeEpisodeTitle(ep.original_name) === normalized) return true
|
||||
const pathTitle = seriesTitleFromPath(ep.path)
|
||||
if (pathTitle && normalizeEpisodeTitle(pathTitle) === normalized) return true
|
||||
|
||||
const siblingTitles = new Set(
|
||||
siblings
|
||||
.map((item) => normalizeEpisodeTitle(item.title))
|
||||
.filter(Boolean),
|
||||
)
|
||||
return siblingTitles.size === 1 && siblingTitles.has(normalized) && siblings.length > 1
|
||||
}
|
||||
|
||||
function normalizeEpisodeTitle(value?: string): string {
|
||||
return (value ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/\s*\((?:19|20)\d{2}\)\s*/g, ' ')
|
||||
.replace(/\s*\{(?:tmdb|tmdbid|douban|bangumi|bgm|thetvdb|tvdb)[\s:=#-]*[a-z0-9_-]+\}\s*/g, ' ')
|
||||
.replace(/[\s._-]+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
@@ -28,6 +28,17 @@ type PlayerVideoStageProps = {
|
||||
onDanmakuCandidates: (candidates: DanmakuAnime[]) => void
|
||||
/** Danmaku settings panel; rendered inside the stage so it stays visible in fullscreen. */
|
||||
danmakuPanel: ReactNode
|
||||
/** Playlist drawer / panel; rendered inside the stage so it stays visible in fullscreen. */
|
||||
playlistPanel?: ReactNode
|
||||
hasPrevEpisode?: boolean
|
||||
hasNextEpisode?: boolean
|
||||
onPrevEpisode?: () => void
|
||||
onNextEpisode?: () => void
|
||||
prevEpisodeTitle?: string
|
||||
nextEpisodeTitle?: string
|
||||
playlistOpen?: boolean
|
||||
hasPlaylist?: boolean
|
||||
onTogglePlaylist?: () => void
|
||||
}
|
||||
|
||||
export function PlayerVideoStage({
|
||||
@@ -49,6 +60,16 @@ export function PlayerVideoStage({
|
||||
onDanmakuLoaded,
|
||||
onDanmakuCandidates,
|
||||
danmakuPanel,
|
||||
playlistPanel,
|
||||
hasPrevEpisode,
|
||||
hasNextEpisode,
|
||||
onPrevEpisode,
|
||||
onNextEpisode,
|
||||
prevEpisodeTitle,
|
||||
nextEpisodeTitle,
|
||||
playlistOpen,
|
||||
hasPlaylist,
|
||||
onTogglePlaylist,
|
||||
}: PlayerVideoStageProps) {
|
||||
const stageRef = useRef<HTMLDivElement>(null)
|
||||
const [videoRatio, setVideoRatio] = useState<number | null>(null)
|
||||
@@ -114,10 +135,11 @@ export function PlayerVideoStage({
|
||||
setActiveCueText('')
|
||||
return
|
||||
}
|
||||
const trackIdx = subtitleIndex
|
||||
|
||||
const updateCue = () => {
|
||||
const trackEls = Array.from(video.querySelectorAll('track'))
|
||||
const selectedEl = trackEls[subtitleIndex]
|
||||
const selectedEl = trackEls[trackIdx]
|
||||
const tt = selectedEl?.track
|
||||
if (!tt) {
|
||||
setActiveCueText('')
|
||||
@@ -151,11 +173,11 @@ export function PlayerVideoStage({
|
||||
const tt = el.track
|
||||
if (tt) {
|
||||
// 'hidden' 模式:浏览器解析 WebVTT 并触发 cuechange,但隐藏原生黑底 UI
|
||||
tt.mode = i === subtitleIndex ? 'hidden' : 'disabled'
|
||||
tt.mode = i === trackIdx ? 'hidden' : 'disabled'
|
||||
}
|
||||
})
|
||||
|
||||
const selected = trackEls[subtitleIndex]
|
||||
const selected = trackEls[trackIdx]
|
||||
if (!selected) return
|
||||
|
||||
const tt = selected.track
|
||||
@@ -184,7 +206,7 @@ export function PlayerVideoStage({
|
||||
video.removeEventListener('seeked', updateCue)
|
||||
video.removeEventListener('playing', updateCue)
|
||||
const trackEls = Array.from(video.querySelectorAll('track'))
|
||||
const selected = trackEls[subtitleIndex]
|
||||
const selected = trackEls[trackIdx]
|
||||
if (selected) {
|
||||
selected.removeEventListener('load', updateCue)
|
||||
if (selected.track) {
|
||||
@@ -241,7 +263,7 @@ export function PlayerVideoStage({
|
||||
src={subtitlesAPI.url(media.id, track.path)}
|
||||
srcLang={track.lang}
|
||||
label={track.label || track.lang}
|
||||
default={index === subtitleIndex}
|
||||
default={subtitleIndex === index}
|
||||
/>
|
||||
))}
|
||||
</video>
|
||||
@@ -282,8 +304,18 @@ export function PlayerVideoStage({
|
||||
danmakuOpen={danmakuOpen}
|
||||
danmakuEnabled={danmakuEnabled}
|
||||
onToggleDanmaku={onToggleDanmaku}
|
||||
hasPrevEpisode={hasPrevEpisode}
|
||||
hasNextEpisode={hasNextEpisode}
|
||||
onPrevEpisode={onPrevEpisode}
|
||||
onNextEpisode={onNextEpisode}
|
||||
prevEpisodeTitle={prevEpisodeTitle}
|
||||
nextEpisodeTitle={nextEpisodeTitle}
|
||||
playlistOpen={playlistOpen}
|
||||
hasPlaylist={hasPlaylist}
|
||||
onTogglePlaylist={onTogglePlaylist}
|
||||
/>
|
||||
{danmakuPanel}
|
||||
{playlistPanel}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sand-500">加载中…</p>
|
||||
|
||||
@@ -7,7 +7,9 @@ import { adminAPI } from '../api/admin'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Library, Setting } from '../types'
|
||||
import { APIConfigsPanel } from '../components/APIConfigsPanel'
|
||||
import { FFToolsPanel } from '../components/FFToolsPanel'
|
||||
import { AdultSettingsPanel } from './AdultSettingsPanel'
|
||||
import { DatabaseSettingsPanel } from './DatabaseSettingsPanel'
|
||||
import { RecognitionWordsPanel } from './RecognitionWordsPanel'
|
||||
import { SettingRow } from './SettingsRow'
|
||||
import { ALL_KEYS, GROUPS } from './settingsGroups'
|
||||
@@ -167,9 +169,11 @@ export function SettingsPage() {
|
||||
|
||||
{!loading && (
|
||||
<div className="space-y-4">
|
||||
{group.key === 'database' && <DatabaseSettingsPanel />}
|
||||
{group.key === 'api-configs' && <APIConfigsPanel />}
|
||||
{group.key === 'recognition-words' && <RecognitionWordsPanel />}
|
||||
{group.key === 'adult' && <AdultSettingsPanel />}
|
||||
{group.key === 'general' && <FFToolsPanel onInstalled={() => refresh().catch(() => undefined)} />}
|
||||
{group.key !== 'adult' && group.key !== 'library' && group.items.length > 0 && (
|
||||
<form onSubmit={onSave} className="glass-panel space-y-4">
|
||||
{group.description && <p className="text-xs text-sand-500">{group.description}</p>}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { artworkScore, groupSeries, type SeriesCard } from '../utils/groupSeries
|
||||
|
||||
export type LibraryPreview = {
|
||||
library: Library
|
||||
items: Media[]
|
||||
items?: Media[]
|
||||
total: number
|
||||
cards: SeriesCard[]
|
||||
}
|
||||
|
||||
@@ -83,12 +83,14 @@ export const generalSettingsGroup: SettingGroup = {
|
||||
label: 'FFmpeg 路径',
|
||||
type: 'text',
|
||||
placeholder: 'ffmpeg',
|
||||
hint: '留空时使用系统 PATH 中的 ffmpeg;也可点击上方「下载并安装」自动下载到 data 目录并自动填入,无需手动填写',
|
||||
},
|
||||
{
|
||||
key: 'ffprobe.path',
|
||||
label: 'FFprobe 路径',
|
||||
type: 'text',
|
||||
placeholder: 'ffprobe',
|
||||
hint: '留空时使用系统 PATH 中的 ffprobe;也可点击上方「下载并安装」自动下载到 data 目录并自动填入',
|
||||
},
|
||||
{
|
||||
key: 'ffprobe.max_concurrent',
|
||||
|
||||
@@ -7,8 +7,16 @@ import type { SettingGroup } from './settingsGroupTypes'
|
||||
|
||||
export type { SettingGroup } from './settingsGroupTypes'
|
||||
|
||||
export const databaseSettingsGroup: SettingGroup = {
|
||||
key: 'database',
|
||||
label: '数据库',
|
||||
description: '配置底层数据库(SQLite / PostgreSQL)及数据平滑迁移',
|
||||
items: [],
|
||||
}
|
||||
|
||||
export const GROUPS: SettingGroup[] = [
|
||||
generalSettingsGroup,
|
||||
databaseSettingsGroup,
|
||||
apiConfigsSettingsGroup,
|
||||
recognitionWordsSettingsGroup,
|
||||
danmakuSettingsGroup,
|
||||
|
||||
@@ -73,8 +73,9 @@ export function useLibraryAdminActions({
|
||||
toast.success(`${label}完成:${selectedSeriesEpisodes.length} 个媒体`)
|
||||
reloadCurrentLibrary()
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || `${label}失败`
|
||||
toast.error(msg)
|
||||
const serverErr = (err as { response?: { data?: { error?: string } } })?.response?.data?.error
|
||||
const msg = serverErr || (err instanceof Error ? err.message : '')
|
||||
toast.error(msg ? `${label}失败:${msg}` : `${label}失败`)
|
||||
} finally {
|
||||
setSeriesToolBusy('')
|
||||
}
|
||||
@@ -146,8 +147,9 @@ export function useLibraryAdminActions({
|
||||
toast.success(`${label}完成:${media.title}`)
|
||||
reloadCurrentLibrary()
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || `${label}失败`
|
||||
toast.error(msg)
|
||||
const serverErr = (err as { response?: { data?: { error?: string } } })?.response?.data?.error
|
||||
const msg = serverErr || (err instanceof Error ? err.message : '')
|
||||
toast.error(msg ? `${label}失败:${msg}` : `${label}失败`)
|
||||
} finally {
|
||||
setMovieToolBusy('')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { Media } from '../types'
|
||||
import { seriesTitle, type SeriesCard } from './groupSeries'
|
||||
|
||||
export type SortField =
|
||||
| 'title'
|
||||
| 'release_date'
|
||||
| 'year'
|
||||
| 'created_at'
|
||||
| 'updated_at'
|
||||
| 'rating'
|
||||
| 'imdb_rating'
|
||||
| 'last_played'
|
||||
| 'duration'
|
||||
| 'bitrate'
|
||||
| 'random'
|
||||
|
||||
export type SortOrder = 'asc' | 'desc'
|
||||
|
||||
export type SortOption = {
|
||||
id: SortField
|
||||
label: string
|
||||
defaultOrder: SortOrder
|
||||
}
|
||||
|
||||
export const SORT_OPTIONS: SortOption[] = [
|
||||
{ id: 'title', label: '标题', defaultOrder: 'asc' },
|
||||
{ id: 'release_date', label: '发行日期', defaultOrder: 'desc' },
|
||||
{ id: 'year', label: '年份', defaultOrder: 'desc' },
|
||||
{ id: 'created_at', label: '加入日期', defaultOrder: 'desc' },
|
||||
{ id: 'updated_at', label: '最后一集添加日期', defaultOrder: 'desc' },
|
||||
{ id: 'rating', label: '影评人评分', defaultOrder: 'desc' },
|
||||
{ id: 'imdb_rating', label: 'IMDB评分', defaultOrder: 'desc' },
|
||||
{ id: 'last_played', label: '播放日期', defaultOrder: 'desc' },
|
||||
{ id: 'duration', label: '播放时长', defaultOrder: 'desc' },
|
||||
{ id: 'bitrate', label: '比特率', defaultOrder: 'desc' },
|
||||
{ id: 'random', label: '随机', defaultOrder: 'desc' },
|
||||
]
|
||||
|
||||
export function getSortOption(field: SortField): SortOption {
|
||||
return SORT_OPTIONS.find((opt) => opt.id === field) ?? SORT_OPTIONS[0]
|
||||
}
|
||||
|
||||
// 简单伪随机洗牌(基于 seed 保证同一渲染周期内稳定)
|
||||
function pseudoRandomShuffle<T>(array: T[], seed = 1): T[] {
|
||||
const result = [...array]
|
||||
let currentSeed = seed
|
||||
const random = () => {
|
||||
currentSeed = (currentSeed * 9301 + 49297) % 233280
|
||||
return currentSeed / 233280
|
||||
}
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(random() * (i + 1))
|
||||
const temp = result[i]
|
||||
result[i] = result[j]
|
||||
result[j] = temp
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function compareStrings(a?: string, b?: string, order: SortOrder = 'asc'): number {
|
||||
const valA = (a ?? '').trim()
|
||||
const valB = (b ?? '').trim()
|
||||
if (!valA && !valB) return 0
|
||||
if (!valA) return 1
|
||||
if (!valB) return -1
|
||||
const cmp = valA.localeCompare(valB, 'zh-CN', { numeric: true, sensitivity: 'base' })
|
||||
return order === 'asc' ? cmp : -cmp
|
||||
}
|
||||
|
||||
function compareNumbers(a: number, b: number, order: SortOrder = 'asc'): number {
|
||||
if (isNaN(a) && isNaN(b)) return 0
|
||||
if (isNaN(a)) return 1
|
||||
if (isNaN(b)) return -1
|
||||
return order === 'asc' ? a - b : b - a
|
||||
}
|
||||
|
||||
function compareDates(a?: string, b?: string, order: SortOrder = 'asc'): number {
|
||||
const timeA = a ? new Date(a).getTime() : 0
|
||||
const timeB = b ? new Date(b).getTime() : 0
|
||||
const safeTimeA = isNaN(timeA) ? 0 : timeA
|
||||
const safeTimeB = isNaN(timeB) ? 0 : timeB
|
||||
if (!safeTimeA && !safeTimeB) return 0
|
||||
if (!safeTimeA) return 1
|
||||
if (!safeTimeB) return -1
|
||||
return order === 'asc' ? safeTimeA - safeTimeB : safeTimeB - safeTimeA
|
||||
}
|
||||
|
||||
export function sortMediaList(
|
||||
items: Media[],
|
||||
field: SortField,
|
||||
order: SortOrder,
|
||||
randomSeed = 1,
|
||||
historyMap?: Map<string, string>,
|
||||
): Media[] {
|
||||
if (!items || items.length === 0) return []
|
||||
if (field === 'random') {
|
||||
return pseudoRandomShuffle(items, randomSeed)
|
||||
}
|
||||
|
||||
const list = [...items]
|
||||
list.sort((a, b) => {
|
||||
switch (field) {
|
||||
case 'title':
|
||||
return compareStrings(a.title || a.original_name, b.title || b.original_name, order)
|
||||
case 'release_date': {
|
||||
const dateA = a.release_date || (a.year ? `${a.year}-01-01` : '')
|
||||
const dateB = b.release_date || (b.year ? `${b.year}-01-01` : '')
|
||||
return compareDates(dateA, dateB, order) || compareStrings(a.title, b.title, 'asc')
|
||||
}
|
||||
case 'year': {
|
||||
const cmp = compareNumbers(a.year || 0, b.year || 0, order)
|
||||
return cmp !== 0 ? cmp : compareStrings(a.title, b.title, 'asc')
|
||||
}
|
||||
case 'created_at':
|
||||
return compareDates(a.created_at, b.created_at, order) || compareStrings(a.title, b.title, 'asc')
|
||||
case 'updated_at':
|
||||
return compareDates(a.updated_at || a.created_at, b.updated_at || b.created_at, order) || compareStrings(a.title, b.title, 'asc')
|
||||
case 'rating':
|
||||
case 'imdb_rating': {
|
||||
const cmp = compareNumbers(a.rating || 0, b.rating || 0, order)
|
||||
return cmp !== 0 ? cmp : compareStrings(a.title, b.title, 'asc')
|
||||
}
|
||||
case 'last_played': {
|
||||
const playedA = historyMap?.get(a.id)
|
||||
const playedB = historyMap?.get(b.id)
|
||||
return compareDates(playedA, playedB, order) || compareStrings(a.title, b.title, 'asc')
|
||||
}
|
||||
case 'duration': {
|
||||
const cmp = compareNumbers(a.duration_sec || 0, b.duration_sec || 0, order)
|
||||
return cmp !== 0 ? cmp : compareStrings(a.title, b.title, 'asc')
|
||||
}
|
||||
case 'bitrate': {
|
||||
// 比特率 / 大小
|
||||
const cmp = compareNumbers(a.size_bytes || 0, b.size_bytes || 0, order)
|
||||
return cmp !== 0 ? cmp : compareStrings(a.title, b.title, 'asc')
|
||||
}
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
export function sortSeriesList(
|
||||
cards: SeriesCard[],
|
||||
field: SortField,
|
||||
order: SortOrder,
|
||||
randomSeed = 1,
|
||||
historyMap?: Map<string, string>,
|
||||
): SeriesCard[] {
|
||||
if (!cards || cards.length === 0) return []
|
||||
if (field === 'random') {
|
||||
return pseudoRandomShuffle(cards, randomSeed)
|
||||
}
|
||||
|
||||
const list = [...cards]
|
||||
list.sort((a, b) => {
|
||||
const repA = a.rep
|
||||
const repB = b.rep
|
||||
const titleA = seriesTitle(repA)
|
||||
const titleB = seriesTitle(repB)
|
||||
|
||||
switch (field) {
|
||||
case 'title':
|
||||
return compareStrings(titleA, titleB, order)
|
||||
case 'release_date': {
|
||||
const dateA = repA.release_date || (repA.year ? `${repA.year}-01-01` : '')
|
||||
const dateB = repB.release_date || (repB.year ? `${repB.year}-01-01` : '')
|
||||
return compareDates(dateA, dateB, order) || compareStrings(titleA, titleB, 'asc')
|
||||
}
|
||||
case 'year': {
|
||||
const cmp = compareNumbers(repA.year || 0, repB.year || 0, order)
|
||||
return cmp !== 0 ? cmp : compareStrings(titleA, titleB, 'asc')
|
||||
}
|
||||
case 'created_at':
|
||||
return compareDates(repA.created_at, repB.created_at, order) || compareStrings(titleA, titleB, 'asc')
|
||||
case 'updated_at': {
|
||||
const updateA = repA.updated_at || repA.created_at
|
||||
const updateB = repB.updated_at || repB.created_at
|
||||
return compareDates(updateA, updateB, order) || compareStrings(titleA, titleB, 'asc')
|
||||
}
|
||||
case 'rating':
|
||||
case 'imdb_rating': {
|
||||
const cmp = compareNumbers(repA.rating || 0, repB.rating || 0, order)
|
||||
return cmp !== 0 ? cmp : compareStrings(titleA, titleB, 'asc')
|
||||
}
|
||||
case 'last_played': {
|
||||
const playedA = historyMap?.get(repA.id)
|
||||
const playedB = historyMap?.get(repB.id)
|
||||
return compareDates(playedA, playedB, order) || compareStrings(titleA, titleB, 'asc')
|
||||
}
|
||||
case 'duration': {
|
||||
const cmp = compareNumbers(repA.duration_sec || 0, repB.duration_sec || 0, order)
|
||||
return cmp !== 0 ? cmp : compareStrings(titleA, titleB, 'asc')
|
||||
}
|
||||
case 'bitrate': {
|
||||
const cmp = compareNumbers(repA.size_bytes || 0, repB.size_bytes || 0, order)
|
||||
return cmp !== 0 ? cmp : compareStrings(titleA, titleB, 'asc')
|
||||
}
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
return list
|
||||
}
|
||||
Reference in New Issue
Block a user