Compare commits

...

10 Commits

Author SHA1 Message Date
github-actions[bot] 1b7d4eef46 chore: bump version to 0.0.51 [skip ci] 2026-08-28 14:20:18 +00:00
truewhile c30dab56a3 优化 2026-08-28 22:20:01 +08:00
github-actions[bot] e365250440 chore: bump version to 0.0.50 [skip ci] 2026-08-28 10:28:29 +00:00
truewhile 47d10e1f58 优化 2026-08-28 18:28:09 +08:00
github-actions[bot] e6473300a7 chore: bump version to 0.0.49 [skip ci] 2026-08-28 08:27:32 +00:00
truewhile 994f64f753 优化 2026-08-28 16:27:11 +08:00
github-actions[bot] 90064a5480 chore: bump version to 0.0.48 [skip ci] 2026-08-27 15:40:31 +00:00
truewhile 6e8eac9887 优化 2026-08-27 23:40:12 +08:00
github-actions[bot] d3051eaffe chore: bump version to 0.0.47 [skip ci] 2026-08-27 13:24:18 +00:00
truewhile 496a897782 优化 2026-08-27 21:24:01 +08:00
63 changed files with 4948 additions and 779 deletions
+1 -1
View File
@@ -1 +1 @@
0.0.46
0.0.51
+2 -2
View File
@@ -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="
)
+41
View File
@@ -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
}
+36
View File
@@ -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)
}
}
+223
View File
@@ -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
}
+71
View File
@@ -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"])
}
}
+11 -11
View File
@@ -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)
+1 -1
View File
@@ -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
}
+16 -13
View File
@@ -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) {
+25 -2
View File
@@ -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
}
+145
View File
@@ -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,
})
}
}
+101
View File
@@ -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())
}
}
+43 -4
View File
@@ -45,6 +45,16 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
}
libs = filtered
}
withPreview := c.Query("with_preview") == "1" || c.Query("with_preview") == "true"
if withPreview {
previews, err := svc.Media.ListLibrariesWithPreview(c.Request.Context(), libs, mediaVisibilityForRequest(c, svc), 10)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, previews)
return
}
c.JSON(http.StatusOK, libs)
}
}
@@ -147,7 +157,9 @@ roots = append(roots, service.LibraryRootInput{Path: req.Path})
}
type updateLibraryReq struct {
CoverURL string `json:"cover_url"`
CoverURL *string `json:"cover_url"`
SortOrder *int `json:"sort_order"`
CarouselEnabled *bool `json:"carousel_enabled"`
}
func updateLibraryHandler(svc *service.Container) gin.HandlerFunc {
@@ -157,9 +169,17 @@ func updateLibraryHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Media.UpdateLibraryCover(c.Request.Context(), c.Param("id"), req.CoverURL); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
if req.CoverURL != nil {
if err := svc.Media.UpdateLibraryCover(c.Request.Context(), c.Param("id"), *req.CoverURL); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
if req.SortOrder != nil || req.CarouselEnabled != nil {
if err := svc.Media.UpdateLibraryFields(c.Request.Context(), c.Param("id"), req.SortOrder, req.CarouselEnabled); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
lib, err := svc.Repo.Library.FindByID(c.Request.Context(), c.Param("id"))
if err != nil || lib == nil {
@@ -170,6 +190,25 @@ func updateLibraryHandler(svc *service.Container) gin.HandlerFunc {
}
}
type reorderLibrariesReq struct {
IDs []string `json:"ids" binding:"required"`
}
func reorderLibrariesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req reorderLibrariesReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Media.ReorderLibraries(c.Request.Context(), req.IDs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"updated": len(req.IDs)})
}
}
func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
+30
View File
@@ -20,6 +20,25 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.
registerAdminAPIConfigRoutes(admin, svc)
registerAdminRecognitionWordRoutes(admin, svc)
registerAdminStrmRoutes(admin, svc)
registerAdminScraperRoutes(admin, svc)
registerAdminDatabaseRoutes(admin, svc)
}
func registerAdminScraperRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.GET("/scraper/queue", listScrapeQueueHandler(svc))
admin.POST("/scraper/queue/:id/cancel", cancelScrapeTaskHandler(svc))
admin.POST("/scraper/queue/:id/retry", retryScrapeTaskHandler(svc))
admin.DELETE("/scraper/queue/:id", deleteScrapeTaskHandler(svc))
admin.POST("/scraper/queue/batch", batchActionScrapeTasksHandler(svc))
admin.POST("/scraper/queue/clear-done", clearDoneScrapeTasksHandler(svc))
admin.POST("/scraper/queue/clear-finished", clearFinishedScrapeTasksHandler(svc))
admin.POST("/scraper/queue/clear-canceled", clearCanceledScrapeTasksHandler(svc))
admin.POST("/scraper/queue/retry-failed", retryAllFailedScrapeTasksHandler(svc))
admin.POST("/scraper/queue/cancel-pending", cancelPendingScrapeTasksHandler(svc))
admin.POST("/scraper/queue/enqueue-library/:id", enqueueLibraryScrapeHandler(svc))
admin.POST("/scraper/queue/enqueue-all", enqueueAllScrapeHandler(svc))
admin.POST("/media/repair-rescrape", enqueueAllScrapeHandler(svc))
admin.POST("/libraries/:id/repair-rescrape", enqueueLibraryScrapeHandler(svc))
}
func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
@@ -50,6 +69,8 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.GET("/strm/downloads", downloadQueueHandler(svc))
admin.POST("/strm/downloads/:id/cancel", cancelStrmDownloadHandler(svc))
admin.POST("/strm/downloads/:id/retry", retryStrmDownloadHandler(svc))
admin.DELETE("/strm/downloads/:id", deleteStrmDownloadHandler(svc))
admin.POST("/strm/downloads/batch", batchActionDownloadsHandler(svc))
admin.POST("/strm/downloads/clear-done", clearDoneDownloadsHandler(svc))
admin.POST("/strm/downloads/clear-finished", clearFinishedDownloadsHandler(svc))
admin.POST("/strm/downloads/clear-canceled", clearCanceledDownloadsHandler(svc))
@@ -58,6 +79,8 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.GET("/strm/uploads", uploadQueueHandler(svc))
admin.POST("/strm/uploads/:id/cancel", cancelStrmUploadHandler(svc))
admin.POST("/strm/uploads/:id/retry", retryStrmUploadHandler(svc))
admin.DELETE("/strm/uploads/:id", deleteStrmUploadHandler(svc))
admin.POST("/strm/uploads/batch", batchActionUploadsHandler(svc))
admin.POST("/strm/uploads/cancel-pending", cancelPendingUploadsHandler(svc))
admin.POST("/strm/uploads/clear-canceled", clearCanceledUploadsHandler(svc))
}
@@ -116,3 +139,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))
}
@@ -21,6 +21,7 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container
authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc))
authed.GET("/libraries/:id", getLibraryHandler(svc))
authed.PATCH("/libraries/:id", middleware.AdminRequired(), updateLibraryHandler(svc))
authed.PUT("/libraries/reorder", middleware.AdminRequired(), reorderLibrariesHandler(svc))
authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc))
authed.GET("/libraries/:id/roots", middleware.AdminRequired(), listLibraryRootsHandler(svc))
authed.POST("/libraries/:id/roots", middleware.AdminRequired(), createLibraryRootHandler(svc))
+167
View File
@@ -0,0 +1,167 @@
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
)
func listScrapeQueueHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
snap, err := svc.Scraper.ScrapeQueueSnapshot(c.Request.Context(), c.Query("status"), page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, snap)
}
}
func cancelScrapeTaskHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Scraper.CancelScrapeTask(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func retryScrapeTaskHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Scraper.RetryScrapeTask(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func deleteScrapeTaskHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Scraper.DeleteScrapeTask(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func batchActionScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req queueBatchReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
n, err := svc.Scraper.BatchActionScrapeTasks(c.Request.Context(), req.Action, req.IDs)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"affected": n, "action": req.Action})
}
}
func clearDoneScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Scraper.ClearDoneScrapeTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
}
}
func clearFinishedScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Scraper.ClearFinishedScrapeTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
}
}
func clearCanceledScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Scraper.ClearCanceledScrapeTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
}
}
func retryAllFailedScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Scraper.RetryAllFailedScrapeTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"retried": n})
}
}
func cancelPendingScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Scraper.CancelPendingScrapeTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"canceled": n})
}
}
type enqueueScrapeReq struct {
EpisodeImages bool `json:"episode_images"`
EpisodeArtwork bool `json:"episode_artwork"`
RefreshMatched bool `json:"refresh_matched"`
IncludeMatched bool `json:"include_matched"`
}
func (r enqueueScrapeReq) toOptions() service.ScrapeOptions {
epArtwork := r.EpisodeImages || r.EpisodeArtwork
return service.ScrapeOptions{
EpisodeArtwork: &epArtwork,
IncludeMatched: r.IncludeMatched || r.RefreshMatched,
RetryNoMatch: true,
}
}
func enqueueLibraryScrapeHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req enqueueScrapeReq
_ = c.ShouldBindJSON(&req)
libID := c.Param("id")
n, err := svc.Scraper.EnqueueLibrary(c.Request.Context(), libID, req.toOptions())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"enqueued": n})
}
}
func enqueueAllScrapeHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req enqueueScrapeReq
_ = c.ShouldBindJSON(&req)
n, err := svc.Scraper.EnqueueAll(c.Request.Context(), req.toOptions())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"enqueued": n})
}
}
+8 -53
View File
@@ -2,7 +2,6 @@
package handler
import (
"context"
"errors"
"io"
"net/http"
@@ -135,28 +134,12 @@ func scrapeOneHandler(svc *service.Container) gin.HandlerFunc {
return
}
options.IncludeMatched = true
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
if err != nil || m == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
task := startScrapeHTTPTask(svc, "手动刮削媒体", m.Title, m.Path)
if err := svc.Scraper.EnrichOneWithOptions(c.Request.Context(), m, options); err != nil {
finishHTTPTask(task, err, "scrape", "手动刮削媒体失败", nil, nil)
task, err := svc.Scraper.EnqueueMedia(c.Request.Context(), c.Param("id"), options)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
reclassified := reclassifyMediaAfterScrape(c.Request.Context(), svc, m.ID)
refreshed, _ := svc.Repo.Media.FindByID(c.Request.Context(), m.ID)
metrics := map[string]int64{"processed": 1}
if refreshed != nil && refreshed.ScrapeStatus == "matched" {
metrics["matched"] = 1
}
if reclassified > 0 {
metrics["reclassified"] = int64(reclassified)
}
finishHTTPTask(task, nil, "completed", "手动刮削媒体结束", metrics, nil)
c.JSON(http.StatusOK, refreshed)
c.JSON(http.StatusOK, task)
}
}
@@ -170,40 +153,12 @@ func scrapeLibraryHandler(svc *service.Container) gin.HandlerFunc {
return
}
options.IncludeMatched = true
var task *service.TaskHandle
if lib, err := svc.Repo.Library.FindByID(c.Request.Context(), libID); err == nil && lib != nil {
task = startScrapeHTTPTask(svc, "手动刮削媒体库", lib.Name, lib.Path)
} else {
task = startScrapeHTTPTask(svc, "手动刮削媒体库", libID, "")
n, err := svc.Scraper.EnqueueLibrary(c.Request.Context(), libID, options)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Run in the background so HTTP returns instantly; the WS hub
// pushes per-item progress on the "scrape" topic.
go func(libID string, task *service.TaskHandle, options service.ScrapeOptions) {
result, err := svc.Scraper.EnrichLibraryDetailedWithOptions(context.Background(), libID, options)
reclassified := 0
if result.Processed > 0 {
reclassified = reclassifyLibraryAfterScrape(context.Background(), svc, libID)
}
metrics := map[string]int64{
"matched": int64(result.Matched),
"processed": int64(result.Processed),
"candidates": int64(result.Candidates),
}
if reclassified > 0 {
metrics["reclassified"] = int64(reclassified)
}
if result.Failed > 0 {
metrics["errors"] = int64(result.Failed)
}
stage := "completed"
message := "手动刮削媒体库结束"
if err != nil {
stage = "scrape"
message = "手动刮削媒体库失败"
}
finishHTTPTask(task, err, stage, message, metrics, nil)
}(libID, task, options)
c.JSON(http.StatusAccepted, gin.H{"status": "scraping"})
c.JSON(http.StatusOK, gin.H{"status": "queued", "enqueued": n})
}
}
+57
View File
@@ -393,6 +393,63 @@ func retryStrmUploadHandler(svc *service.Container) gin.HandlerFunc {
}
}
func deleteStrmDownloadHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Strm.DeleteDownloadTask(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func deleteStrmUploadHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Strm.DeleteUploadTask(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
type queueBatchReq struct {
Action string `json:"action" binding:"required"`
IDs []string `json:"ids" binding:"required"`
}
func batchActionDownloadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req queueBatchReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
n, err := svc.Strm.BatchActionDownloadTasks(c.Request.Context(), req.Action, req.IDs)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"affected": n, "action": req.Action})
}
}
func batchActionUploadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req queueBatchReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
n, err := svc.Strm.BatchActionUploadTasks(c.Request.Context(), req.Action, req.IDs)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"affected": n, "action": req.Action})
}
}
// ─── 下载队列批量操作 ─────────────────────────────────────────────────────────
func clearDoneDownloadsHandler(svc *service.Container) gin.HandlerFunc {
+8 -6
View File
@@ -3,12 +3,14 @@ package model
// Library 表示一个逻辑媒体库。Path 保留为兼容字段,指向第一条 LibraryRoot。
type Library struct {
Base
Name string `gorm:"size:128;not null" json:"name"`
Path string `gorm:"size:1024;not null" json:"path"`
Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music
CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"`
Enabled bool `gorm:"default:true" json:"enabled"`
Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"`
Name string `gorm:"size:128;not null" json:"name"`
Path string `gorm:"size:1024;not null" json:"path"`
Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music
CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"`
Enabled bool `gorm:"default:true" json:"enabled"`
SortOrder int `gorm:"index;default:0" json:"sort_order"` // 手动拖拽排序用,越小越靠前
CarouselEnabled bool `gorm:"default:false" json:"carousel_enabled"` // 是否参与首页海报轮播(默认不参与)
Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"`
}
// LibraryRoot 是逻辑媒体库下的一条真实物理/挂载路径。
+1
View File
@@ -57,5 +57,6 @@ func AllModels() []interface{} {
&StrmDownloadTask{},
&StrmUploadTask{},
&StrmDirCache{},
&ScrapeTask{},
}
}
+34
View File
@@ -0,0 +1,34 @@
package model
import "time"
const (
ScrapeTaskPending = "pending"
ScrapeTaskRunning = "running"
ScrapeTaskDone = "done"
ScrapeTaskFailed = "failed"
ScrapeTaskCanceled = "canceled"
)
// ScrapeTask 表示一条持久化的媒体刮削任务。
type ScrapeTask struct {
Base
MediaID string `gorm:"index;size:36" json:"media_id"`
LibraryID string `gorm:"index;size:36" json:"library_id"`
LibraryName string `gorm:"size:128" json:"library_name"`
MediaTitle string `gorm:"size:255;not null" json:"media_title"`
MediaPath string `gorm:"size:1024;not null" json:"media_path"`
MediaType string `gorm:"size:16" json:"media_type"` // movie / tv / anime / adult
Provider string `gorm:"size:32" json:"provider"` // tmdb / douban / bangumi / thetvdb / metatube
MatchedTitle string `gorm:"size:255" json:"matched_title"`
MatchedYear int `json:"matched_year"`
PosterURL string `gorm:"size:1024" json:"poster_url"`
BackdropURL string `gorm:"size:1024" json:"backdrop_url"`
Status string `gorm:"index;size:16;default:pending" json:"status"` // pending / running / done / failed / canceled
Error string `gorm:"type:text" json:"error"`
RetryCount int `gorm:"default:0" json:"retry_count"`
EpisodeImages bool `gorm:"default:true" json:"episode_images"`
RefreshMatched bool `gorm:"default:false" json:"refresh_matched"`
StartedAt *time.Time `json:"started_at,omitempty"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
}
+28 -1
View File
@@ -15,6 +15,11 @@ type LibraryRepository struct{ db *gorm.DB }
// Create persists a new library row.
func (r *LibraryRepository) Create(ctx context.Context, l *model.Library) error {
if l != nil && l.SortOrder == 0 {
var maxSort int
_ = r.db.WithContext(ctx).Model(&model.Library{}).Select("COALESCE(MAX(sort_order), -1)").Scan(&maxSort)
l.SortOrder = maxSort + 1
}
return r.db.WithContext(ctx).Create(l).Error
}
@@ -23,6 +28,11 @@ func (r *LibraryRepository) CreateWithRoots(ctx context.Context, l *model.Librar
return r.Create(ctx, l)
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if l != nil && l.SortOrder == 0 {
var maxSort int
_ = tx.Model(&model.Library{}).Select("COALESCE(MAX(sort_order), -1)").Scan(&maxSort)
l.SortOrder = maxSort + 1
}
if err := tx.Create(l).Error; err != nil {
return err
}
@@ -50,7 +60,7 @@ func (r *LibraryRepository) CreateWithRoots(ctx context.Context, l *model.Librar
// List returns all enabled+disabled libraries.
func (r *LibraryRepository) List(ctx context.Context) ([]model.Library, error) {
var ls []model.Library
q := r.db.WithContext(ctx).Order("created_at asc")
q := r.db.WithContext(ctx).Order("sort_order asc, created_at asc")
if r.hasLibraryRootsTable() {
q = q.Preload("Roots", func(db *gorm.DB) *gorm.DB {
return db.Order("sort_order asc, created_at asc")
@@ -60,6 +70,23 @@ func (r *LibraryRepository) List(ctx context.Context) ([]model.Library, error) {
return ls, err
}
// SetSortOrder assigns sort_order to libraries, preserving position order for
// any library not present in the provided map.
func (r *LibraryRepository) SetSortOrder(ctx context.Context, ids []string) error {
if len(ids) == 0 {
return nil
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for i, id := range ids {
if err := tx.Model(&model.Library{}).Where("id = ?", id).
Update("sort_order", i).Error; err != nil {
return err
}
}
return nil
})
}
// FindByID returns the library, or (nil, nil) when missing.
func (r *LibraryRepository) FindByID(ctx context.Context, id string) (*model.Library, error) {
var l model.Library
+29
View File
@@ -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 清理)。
+5 -3
View File
@@ -30,9 +30,10 @@ type Container struct {
StrmSyncPath *StrmSyncPathRepository
StrmSyncRecord *StrmSyncRecordRepository
StrmDownload *StrmDownloadTaskRepository
StrmUpload *StrmUploadTaskRepository
StrmDirCache *StrmDirCacheRepository
}
StrmUpload *StrmUploadTaskRepository
StrmDirCache *StrmDirCacheRepository
ScrapeTask *ScrapeTaskRepository
}
// New 将每个 repository 连接到单个 *gorm.DB。
func New(db *gorm.DB) *Container {
@@ -60,5 +61,6 @@ func New(db *gorm.DB) *Container {
StrmDownload: &StrmDownloadTaskRepository{db: db},
StrmUpload: &StrmUploadTaskRepository{db: db},
StrmDirCache: &StrmDirCacheRepository{db: db},
ScrapeTask: &ScrapeTaskRepository{db: db},
}
}
@@ -0,0 +1,274 @@
package repository
import (
"context"
"errors"
"strings"
"sync"
"time"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/model"
)
var scrapeClaimMu sync.Mutex
// ScrapeTaskRepository persists model.ScrapeTask.
type ScrapeTaskRepository struct{ db *gorm.DB }
func (r *ScrapeTaskRepository) Create(ctx context.Context, t *model.ScrapeTask) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Create(t).Error
})
}
func (r *ScrapeTaskRepository) CreateBatch(ctx context.Context, tasks []model.ScrapeTask) error {
if len(tasks) == 0 {
return nil
}
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).CreateInBatches(tasks, 100).Error
})
}
func (r *ScrapeTaskRepository) FindByID(ctx context.Context, id string) (*model.ScrapeTask, error) {
var t model.ScrapeTask
err := r.db.WithContext(ctx).Where("id = ?", id).First(&t).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &t, err
}
func (r *ScrapeTaskRepository) FindActiveByMediaID(ctx context.Context, mediaID string) (*model.ScrapeTask, error) {
var t model.ScrapeTask
err := r.db.WithContext(ctx).
Where("media_id = ? AND status IN ?", mediaID, []string{model.ScrapeTaskPending, model.ScrapeTaskRunning}).
First(&t).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &t, err
}
func (r *ScrapeTaskRepository) List(ctx context.Context, status string, page, pageSize int) ([]model.ScrapeTask, int64, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 200 {
pageSize = 50
}
q := r.db.WithContext(ctx).Model(&model.ScrapeTask{})
if strings.TrimSpace(status) != "" && status != "all" {
q = q.Where("status = ?", strings.TrimSpace(status))
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []model.ScrapeTask
err := q.Order("created_at desc").
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&rows).Error
return rows, total, err
}
func (r *ScrapeTaskRepository) CountByStatus(ctx context.Context) (map[string]int64, error) {
var rows []struct {
Status string
Count int64
}
err := r.db.WithContext(ctx).Model(&model.ScrapeTask{}).
Select("status, count(*) as count").
Group("status").Scan(&rows).Error
if err != nil {
return nil, err
}
out := map[string]int64{}
for _, row := range rows {
out[row.Status] = row.Count
}
return out, nil
}
// ClaimPending picks pending scrape tasks and marks them running.
func (r *ScrapeTaskRepository) ClaimPending(ctx context.Context, limit int) ([]model.ScrapeTask, error) {
scrapeClaimMu.Lock()
defer scrapeClaimMu.Unlock()
var rows []model.ScrapeTask
err := withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("status = ?", model.ScrapeTaskPending).
Order("created_at asc").Limit(limit).Find(&rows).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
ids := make([]string, 0, len(rows))
now := time.Now()
for i := range rows {
ids = append(ids, rows[i].ID)
rows[i].Status = model.ScrapeTaskRunning
rows[i].StartedAt = &now
}
return tx.Model(&model.ScrapeTask{}).Where("id IN ?", ids).
Updates(map[string]any{"status": model.ScrapeTaskRunning, "started_at": now}).Error
})
})
if err != nil {
return nil, err
}
return rows, nil
}
func (r *ScrapeTaskRepository) Update(ctx context.Context, t *model.ScrapeTask) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.ScrapeTask{}).Where("id = ?", t.ID).Updates(map[string]any{
"status": t.Status,
"error": t.Error,
"provider": t.Provider,
"matched_title": t.MatchedTitle,
"matched_year": t.MatchedYear,
"poster_url": t.PosterURL,
"backdrop_url": t.BackdropURL,
"retry_count": t.RetryCount,
"started_at": t.StartedAt,
"finished_at": t.FinishedAt,
"updated_at": time.Now(),
}).Error
})
}
func (r *ScrapeTaskRepository) Delete(ctx context.Context, id string) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.ScrapeTask{}).Error
})
}
func (r *ScrapeTaskRepository) DeleteBatch(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("id IN ?", ids).Delete(&model.ScrapeTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
func (r *ScrapeTaskRepository) RetryBatch(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.ScrapeTask{}).
Where("id IN ? AND status IN ?", ids, []string{model.ScrapeTaskFailed, model.ScrapeTaskCanceled}).
Updates(map[string]any{
"status": model.ScrapeTaskPending,
"error": "",
"retry_count": 0,
"started_at": nil,
"finished_at": nil,
"updated_at": time.Now(),
})
count = res.RowsAffected
return res.Error
})
return count, err
}
func (r *ScrapeTaskRepository) CancelBatch(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
now := time.Now()
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.ScrapeTask{}).
Where("id IN ? AND status IN ?", ids, []string{model.ScrapeTaskPending, model.ScrapeTaskRunning}).
Updates(map[string]any{
"status": model.ScrapeTaskCanceled,
"error": "已批量取消",
"finished_at": now,
"updated_at": now,
})
count = res.RowsAffected
return res.Error
})
return count, err
}
func (r *ScrapeTaskRepository) ClearDone(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.ScrapeTaskDone).Delete(&model.ScrapeTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
func (r *ScrapeTaskRepository) ClearFinished(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("status IN ?", []string{model.ScrapeTaskDone, model.ScrapeTaskFailed, model.ScrapeTaskCanceled}).
Delete(&model.ScrapeTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
func (r *ScrapeTaskRepository) ClearCanceled(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.ScrapeTaskCanceled).Delete(&model.ScrapeTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
func (r *ScrapeTaskRepository) RetryAllFailed(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.ScrapeTask{}).
Where("status = ?", model.ScrapeTaskFailed).
Updates(map[string]any{
"status": model.ScrapeTaskPending,
"error": "",
"retry_count": 0,
"started_at": nil,
"finished_at": nil,
"updated_at": time.Now(),
})
count = res.RowsAffected
return res.Error
})
return count, err
}
func (r *ScrapeTaskRepository) CancelPending(ctx context.Context) (int64, error) {
now := time.Now()
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.ScrapeTask{}).
Where("status IN ?", []string{model.ScrapeTaskPending, model.ScrapeTaskRunning}).
Updates(map[string]any{
"status": model.ScrapeTaskCanceled,
"error": "已批量取消",
"finished_at": now,
"updated_at": now,
})
count = res.RowsAffected
return res.Error
})
return count, err
}
+120
View File
@@ -308,6 +308,66 @@ func (r *StrmDownloadTaskRepository) Delete(ctx context.Context, id string) erro
})
}
// DeleteBatch 批量删除指定 ID 的下载任务。
func (r *StrmDownloadTaskRepository) DeleteBatch(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("id IN ?", ids).Delete(&model.StrmDownloadTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
// RetryBatch 批量重试指定 ID 的失败/已取消下载任务。
func (r *StrmDownloadTaskRepository) RetryBatch(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
Where("id IN ? AND status IN ?", ids, []string{model.StrmTaskFailed, model.StrmTaskCanceled}).
Updates(map[string]any{
"status": model.StrmTaskPending,
"error": "",
"retry_count": 0,
"next_try_at": nil,
"started_at": nil,
"finished_at": nil,
"updated_at": time.Now(),
})
count = res.RowsAffected
return res.Error
})
return count, err
}
// CancelBatch 批量取消指定 ID 的排队/进行中下载任务。
func (r *StrmDownloadTaskRepository) CancelBatch(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
now := time.Now()
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
Where("id IN ? AND status IN ?", ids, []string{model.StrmTaskPending, model.StrmTaskRunning}).
Updates(map[string]any{
"status": model.StrmTaskCanceled,
"error": "已批量取消",
"finished_at": now,
"updated_at": now,
})
count = res.RowsAffected
return res.Error
})
return count, err
}
// ClearDone 清空全部已完成下载任务。
func (r *StrmDownloadTaskRepository) ClearDone(ctx context.Context) (int64, error) {
var count int64
@@ -555,6 +615,66 @@ func (r *StrmUploadTaskRepository) Delete(ctx context.Context, id string) error
})
}
// DeleteBatch 批量删除指定 ID 的上传任务。
func (r *StrmUploadTaskRepository) DeleteBatch(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("id IN ?", ids).Delete(&model.StrmUploadTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
// RetryBatch 批量重试指定 ID 的失败/已取消上传任务。
func (r *StrmUploadTaskRepository) RetryBatch(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
Where("id IN ? AND status IN ?", ids, []string{model.StrmTaskFailed, model.StrmTaskCanceled}).
Updates(map[string]any{
"status": model.StrmTaskPending,
"error": "",
"retry_count": 0,
"next_try_at": nil,
"started_at": nil,
"finished_at": nil,
"updated_at": time.Now(),
})
count = res.RowsAffected
return res.Error
})
return count, err
}
// CancelBatch 批量取消指定 ID 的排队/进行中上传任务。
func (r *StrmUploadTaskRepository) CancelBatch(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
now := time.Now()
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
Where("id IN ? AND status IN ?", ids, []string{model.StrmTaskPending, model.StrmTaskRunning}).
Updates(map[string]any{
"status": model.StrmTaskCanceled,
"error": "已批量取消",
"finished_at": now,
"updated_at": now,
})
count = res.RowsAffected
return res.Error
})
return count, err
}
// ClearCanceled 清空全部已取消上传任务。
func (r *StrmUploadTaskRepository) ClearCanceled(ctx context.Context) (int64, error) {
var count int64
+32 -8
View File
@@ -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))
}
}
+90
View File
@@ -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
}
+8 -5
View File
@@ -32,18 +32,21 @@ func (s *ScraperService) manualTMDbCandidates(ctx context.Context, query string,
for _, typ := range manualTMDbSearchTypes(mediaType) {
switch typ {
case "movie":
if matches, err := s.tmdb.SearchMovieCandidates(ctx, query, year); err == nil {
if matches, err := s.tmdb.SearchMovieCandidates(ctx, query, year); err == nil && len(matches) > 0 {
for _, match := range matches {
out = append(out, manualTMDbCandidate{MediaType: "movie", Match: match})
}
}
case "tv":
if matches, err := s.tmdb.SearchTVCandidates(ctx, query, year); err == nil {
if matches, err := s.tmdb.SearchTVCandidates(ctx, query, year); err == nil && len(matches) > 0 {
for _, match := range matches {
out = append(out, manualTMDbCandidate{MediaType: "tv", Match: match})
}
}
}
if len(out) > 0 {
break
}
}
return out
}
@@ -70,15 +73,15 @@ func manualTMDbIDSearchTypes(mediaType string) []string {
func manualTMDbSearchTypes(mediaType string) []string {
if strings.TrimSpace(mediaType) == "" {
return []string{"movie", "tv"}
return []string{"tv", "movie"}
}
switch normalizeMediaType(mediaType, "", "") {
case "tv", "anime", "variety":
return []string{"tv", "movie"}
case "movie", "adult":
return []string{"movie"}
default:
return []string{"movie", "tv"}
default:
return []string{"tv", "movie"}
}
}
+3 -3
View File
@@ -179,9 +179,9 @@ func TestManualSearchFallsBackToMovieFolderForGenericQuery(t *testing.T) {
if len(results) != 1 || results[0].TMDbID != 27205 {
t.Fatalf("manual search results=%#v, want folder fallback candidate; queries=%v", results, queries)
}
if len(queries) < 2 || queries[0] != "00000" || queries[1] != "inception" {
t.Fatalf("manual search queries=%v, want explicit query then folder fallback", queries)
}
if len(queries) < 2 || queries[0] != "00000" || queries[len(queries)-1] != "inception" {
t.Fatalf("manual search queries=%v, want explicit query then folder fallback", queries)
}
}
func TestManualSearchReturnsMovieFallbackForTVTypedTMDbSearch(t *testing.T) {
+67
View File
@@ -4,14 +4,81 @@ 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
}
}
for i := range out {
if out[i].Total == 0 {
continue
}
items, _, err := s.repo.Media.ListByLibrariesFiltered(ctx, []string{out[i].ID}, 0, 60, 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 {
+29 -1
View File
@@ -52,7 +52,14 @@ func (s *MediaService) CreateLibraryWithRootsAndCover(ctx context.Context, name,
s.invalidateMediaCache(ctx)
return lib, nil
}
lib := &model.Library{Name: strings.TrimSpace(name), Path: roots[0].Path, Type: kind, CoverURL: strings.TrimSpace(coverURL), Enabled: true}
lib := &model.Library{
Name: strings.TrimSpace(name),
Path: roots[0].Path,
Type: kind,
CoverURL: strings.TrimSpace(coverURL),
Enabled: true,
CarouselEnabled: false,
}
if err := s.repo.Library.CreateWithRoots(ctx, lib, roots); err != nil {
return nil, err
}
@@ -105,6 +112,27 @@ func (s *MediaService) UpdateLibraryCover(ctx context.Context, libraryID, coverU
Update("cover_url", strings.TrimSpace(coverURL)).Error
}
// UpdateLibraryFields updates sort_order / carousel_enabled on a library.
func (s *MediaService) UpdateLibraryFields(ctx context.Context, libraryID string, sortOrder *int, carouselEnabled *bool) error {
updates := map[string]any{}
if sortOrder != nil {
updates["sort_order"] = *sortOrder
}
if carouselEnabled != nil {
updates["carousel_enabled"] = *carouselEnabled
}
if len(updates) == 0 || strings.TrimSpace(libraryID) == "" {
return nil
}
return s.repo.DB.WithContext(ctx).Model(&model.Library{}).
Where("id = ?", libraryID).Updates(updates).Error
}
// ReorderLibraries persists a full media-library ordering.
func (s *MediaService) ReorderLibraries(ctx context.Context, ids []string) error {
return s.repo.Library.SetSortOrder(ctx, ids)
}
func (s *MediaService) findLogicalLibrary(ctx context.Context, name, kind string) (*model.Library, error) {
if s == nil || s.repo == nil || s.repo.Library == nil {
return nil, nil
+94
View File
@@ -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)
}
}
+354
View File
@@ -0,0 +1,354 @@
package service
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/model"
)
type ScrapeQueueCounts struct {
Pending int64 `json:"pending"`
Running int64 `json:"running"`
Done int64 `json:"done"`
Failed int64 `json:"failed"`
Canceled int64 `json:"canceled"`
}
type ScrapeQueueSnapshot struct {
Counts ScrapeQueueCounts `json:"counts"`
Tasks []model.ScrapeTask `json:"tasks"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// Start 启动刮削任务队列的后台消费者。
func (s *ScraperService) Start(ctx context.Context) {
if s == nil {
return
}
go s.queueWorker(ctx)
}
func (s *ScraperService) queueWorker(ctx context.Context) {
const claimBatch = 4
sem := make(chan struct{}, 2) // 最大并发刮削数:2
for {
select {
case <-ctx.Done():
return
default:
}
tasks, err := s.repo.ScrapeTask.ClaimPending(ctx, claimBatch)
if err != nil {
if s.log != nil {
s.log.Warn("claim pending scrape task failed", zap.Error(err))
}
sleepContext(ctx, 3*time.Second)
continue
}
if len(tasks) == 0 {
sleepContext(ctx, 2*time.Second)
continue
}
var wg sync.WaitGroup
for i := range tasks {
wg.Add(1)
go func(t *model.ScrapeTask) {
defer wg.Done()
select {
case <-ctx.Done():
return
case sem <- struct{}{}:
}
defer func() { <-sem }()
s.processScrapeTask(ctx, t)
}(&tasks[i])
}
wg.Wait()
}
}
func (s *ScraperService) processScrapeTask(ctx context.Context, task *model.ScrapeTask) {
media, err := s.repo.Media.FindByID(ctx, task.MediaID)
if err != nil || media == nil {
now := time.Now()
task.Status = model.ScrapeTaskFailed
task.Error = "媒体项已不存在或被删除"
task.FinishedAt = &now
_ = s.repo.ScrapeTask.Update(ctx, task)
return
}
epArtwork := task.EpisodeImages
options := ScrapeOptions{
EpisodeArtwork: &epArtwork,
IncludeMatched: task.RefreshMatched,
RetryNoMatch: true,
}
enrichErr := s.EnrichOneWithOptions(ctx, media, options)
now := time.Now()
task.FinishedAt = &now
refreshed, _ := s.repo.Media.FindByID(ctx, media.ID)
if refreshed != nil && refreshed.ScrapeStatus == "matched" {
task.Status = model.ScrapeTaskDone
task.Error = ""
task.MatchedTitle = refreshed.Title
task.MatchedYear = refreshed.Year
task.PosterURL = refreshed.PosterURL
task.BackdropURL = refreshed.BackdropURL
if refreshed.TMDbID > 0 {
task.Provider = "tmdb"
} else if strings.TrimSpace(refreshed.DoubanID) != "" {
task.Provider = "douban"
} else if refreshed.BangumiID > 0 {
task.Provider = "bangumi"
} else if strings.TrimSpace(refreshed.TheTVDBID) != "" {
task.Provider = "thetvdb"
} else {
task.Provider = "metatube"
}
} else {
task.Status = model.ScrapeTaskFailed
if enrichErr != nil {
task.Error = enrichErr.Error()
} else if refreshed != nil && refreshed.ScrapeStatus == "no_match" {
task.Error = "未搜索到匹配的元数据"
} else {
task.Error = "刮削未完成匹配"
}
}
_ = s.repo.ScrapeTask.Update(ctx, task)
if s.hub != nil {
s.hub.Publish("scraper_queue", map[string]any{
"task_id": task.ID,
"status": task.Status,
"title": task.MediaTitle,
})
}
}
func (s *ScraperService) mediaKind(m *model.Media, lib *model.Library) string {
if m == nil {
return ""
}
if lib != nil && lib.Type != "" {
return lib.Type
}
if mediaIsEpisodic(m, lib) {
return "tv"
}
return "movie"
}
// EnqueueMedia 把单个媒体项放入刮削队列。
func (s *ScraperService) EnqueueMedia(ctx context.Context, mediaID string, options ScrapeOptions) (*model.ScrapeTask, error) {
if s == nil || s.repo == nil {
return nil, errors.New("scraper service not initialized")
}
media, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil || media == nil {
return nil, errors.New("media not found")
}
if active, _ := s.repo.ScrapeTask.FindActiveByMediaID(ctx, mediaID); active != nil {
return active, nil
}
libName := ""
var lib *model.Library
if strings.TrimSpace(media.LibraryID) != "" {
lib, _ = s.repo.Library.FindByID(ctx, media.LibraryID)
if lib != nil {
libName = lib.Name
}
}
task := &model.ScrapeTask{
MediaID: media.ID,
LibraryID: media.LibraryID,
LibraryName: libName,
MediaTitle: media.Title,
MediaPath: media.Path,
MediaType: s.mediaKind(media, lib),
Status: model.ScrapeTaskPending,
EpisodeImages: options.episodeArtworkEnabled(),
RefreshMatched: options.IncludeMatched || options.RefreshWeakMatched,
}
if err := s.repo.ScrapeTask.Create(ctx, task); err != nil {
return nil, err
}
return task, nil
}
// EnqueueLibrary 把指定媒体库内的所有候选媒体批量推入刮削队列。
func (s *ScraperService) EnqueueLibrary(ctx context.Context, libraryID string, options ScrapeOptions) (int, error) {
if s == nil || s.repo == nil {
return 0, errors.New("scraper service not initialized")
}
lib, err := s.repo.Library.FindByID(ctx, libraryID)
if err != nil || lib == nil {
return 0, errors.New("library not found")
}
rows, err := s.scrapeCandidateRows(ctx, libraryID, options)
if err != nil {
return 0, err
}
if len(rows) == 0 {
return 0, nil
}
tasks := make([]model.ScrapeTask, 0, len(rows))
for _, m := range rows {
tasks = append(tasks, model.ScrapeTask{
MediaID: m.ID,
LibraryID: lib.ID,
LibraryName: lib.Name,
MediaTitle: m.Title,
MediaPath: m.Path,
MediaType: s.mediaKind(&m, lib),
Status: model.ScrapeTaskPending,
EpisodeImages: options.episodeArtworkEnabled(),
RefreshMatched: options.IncludeMatched || options.RefreshWeakMatched,
})
}
if err := s.repo.ScrapeTask.CreateBatch(ctx, tasks); err != nil {
return 0, err
}
return len(tasks), nil
}
// EnqueueAll 把所有已启用媒体库的媒体推入刮削队列。
func (s *ScraperService) EnqueueAll(ctx context.Context, options ScrapeOptions) (int, error) {
libs, err := s.repo.Library.List(ctx)
if err != nil {
return 0, err
}
total := 0
for _, lib := range libs {
if !lib.Enabled {
continue
}
n, err := s.EnqueueLibrary(ctx, lib.ID, options)
if err != nil {
if s.log != nil {
s.log.Warn("enqueue library for scrape failed", zap.String("library", lib.ID), zap.Error(err))
}
continue
}
total += n
}
return total, nil
}
func (s *ScraperService) ScrapeQueueSnapshot(ctx context.Context, status string, page, pageSize int) (*ScrapeQueueSnapshot, error) {
tasks, total, err := s.repo.ScrapeTask.List(ctx, status, page, pageSize)
if err != nil {
return nil, err
}
countsMap, err := s.repo.ScrapeTask.CountByStatus(ctx)
if err != nil {
return nil, err
}
snap := &ScrapeQueueSnapshot{
Counts: ScrapeQueueCounts{
Pending: countsMap[model.ScrapeTaskPending],
Running: countsMap[model.ScrapeTaskRunning],
Done: countsMap[model.ScrapeTaskDone],
Failed: countsMap[model.ScrapeTaskFailed],
Canceled: countsMap[model.ScrapeTaskCanceled],
},
Tasks: tasks,
Total: total,
Page: page,
PageSize: pageSize,
}
return snap, nil
}
func (s *ScraperService) CancelScrapeTask(ctx context.Context, id string) error {
task, err := s.repo.ScrapeTask.FindByID(ctx, id)
if err != nil || task == nil {
return errors.New("刮削任务不存在")
}
if task.Status != model.ScrapeTaskPending && task.Status != model.ScrapeTaskRunning {
return errors.New("任务已完成或已终止,无法取消")
}
now := time.Now()
task.Status = model.ScrapeTaskCanceled
task.Error = "已取消"
task.FinishedAt = &now
return s.repo.ScrapeTask.Update(ctx, task)
}
func (s *ScraperService) RetryScrapeTask(ctx context.Context, id string) error {
task, err := s.repo.ScrapeTask.FindByID(ctx, id)
if err != nil || task == nil {
return errors.New("刮削任务不存在")
}
if task.Status != model.ScrapeTaskFailed && task.Status != model.ScrapeTaskCanceled {
return errors.New("只有失败或已取消的任务可以重试")
}
task.Status = model.ScrapeTaskPending
task.Error = ""
task.RetryCount = 0
task.StartedAt = nil
task.FinishedAt = nil
return s.repo.ScrapeTask.Update(ctx, task)
}
func (s *ScraperService) DeleteScrapeTask(ctx context.Context, id string) error {
return s.repo.ScrapeTask.Delete(ctx, id)
}
func (s *ScraperService) BatchActionScrapeTasks(ctx context.Context, action string, ids []string) (int64, error) {
switch action {
case "delete":
return s.repo.ScrapeTask.DeleteBatch(ctx, ids)
case "retry":
return s.repo.ScrapeTask.RetryBatch(ctx, ids)
case "cancel":
return s.repo.ScrapeTask.CancelBatch(ctx, ids)
default:
return 0, fmt.Errorf("不支持的操作: %s", action)
}
}
func (s *ScraperService) ClearDoneScrapeTasks(ctx context.Context) (int64, error) {
return s.repo.ScrapeTask.ClearDone(ctx)
}
func (s *ScraperService) ClearFinishedScrapeTasks(ctx context.Context) (int64, error) {
return s.repo.ScrapeTask.ClearFinished(ctx)
}
func (s *ScraperService) ClearCanceledScrapeTasks(ctx context.Context) (int64, error) {
return s.repo.ScrapeTask.ClearCanceled(ctx)
}
func (s *ScraperService) RetryAllFailedScrapeTasks(ctx context.Context) (int64, error) {
return s.repo.ScrapeTask.RetryAllFailed(ctx)
}
func (s *ScraperService) CancelPendingScrapeTasks(ctx context.Context) (int64, error) {
return s.repo.ScrapeTask.CancelPending(ctx)
}
+11 -5
View File
@@ -58,11 +58,12 @@ type Container struct {
Device *DeviceService
Cache *RuntimeCacheService
Sessions *SessionTrackerService
RecognitionWords *RecognitionWordsService
Danmaku *DanmakuService
Strm *StrmService
RecognitionWords *RecognitionWordsService
Danmaku *DanmakuService
Strm *StrmService
Database *DatabaseAdminService
stopCtx context.Context
stopCtx context.Context
stopCancel context.CancelFunc
// ReloadHTTPServer 由 cmd/server 注入。HTTPS 相关设置保存后,handler
@@ -102,7 +103,12 @@ func (c *Container) Boot() {
c.Strm.Start(c.stopCtx)
}
// Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。
// 启动刮削队列后台消费者
if c.Scraper != nil {
c.Scraper.Start(c.stopCtx)
}
// Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。
// 每天触发一次评估;规则里的窗口可随机,不固定。
if c.Device != nil {
go c.runInactivitySweeper(c.stopCtx)
+1
View File
@@ -118,6 +118,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(
+38
View File
@@ -551,6 +551,44 @@ func (s *StrmService) RetryUploadTask(ctx context.Context, id string) error {
// ─── 下载队列批量操作(handler 使用) ─────────────────────────────────────────
// DeleteDownloadTask 删除一个下载任务记录。
func (s *StrmService) DeleteDownloadTask(ctx context.Context, id string) error {
return s.repo.StrmDownload.Delete(ctx, id)
}
// DeleteUploadTask 删除一个上传任务记录。
func (s *StrmService) DeleteUploadTask(ctx context.Context, id string) error {
return s.repo.StrmUpload.Delete(ctx, id)
}
// BatchActionDownloadTasks 对选中的下载任务执行批量操作(delete / retry / cancel)。
func (s *StrmService) BatchActionDownloadTasks(ctx context.Context, action string, ids []string) (int64, error) {
switch action {
case "delete":
return s.repo.StrmDownload.DeleteBatch(ctx, ids)
case "retry":
return s.repo.StrmDownload.RetryBatch(ctx, ids)
case "cancel":
return s.repo.StrmDownload.CancelBatch(ctx, ids)
default:
return 0, fmt.Errorf("不支持的批量操作: %s", action)
}
}
// BatchActionUploadTasks 对选中的上传任务执行批量操作(delete / retry / cancel)。
func (s *StrmService) BatchActionUploadTasks(ctx context.Context, action string, ids []string) (int64, error) {
switch action {
case "delete":
return s.repo.StrmUpload.DeleteBatch(ctx, ids)
case "retry":
return s.repo.StrmUpload.RetryBatch(ctx, ids)
case "cancel":
return s.repo.StrmUpload.CancelBatch(ctx, ids)
default:
return 0, fmt.Errorf("不支持的批量操作: %s", action)
}
}
// ClearDoneDownloadTasks 清空全部已完成下载记录,返回删除数量。
func (s *StrmService) ClearDoneDownloadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmDownload.ClearDone(ctx)
+16 -4
View File
@@ -57,7 +57,7 @@ func (t *TMDbProvider) searchMovieCandidates(ctx context.Context, query string,
apiKey := t.resolveAPIKey(ctx)
if apiKey == "" {
return nil, nil
return nil, errors.New("TMDb API Key 未配置,请先在「系统设置 → API配置」中填写")
}
base := t.resolveBaseURL(ctx)
@@ -65,7 +65,7 @@ func (t *TMDbProvider) searchMovieCandidates(ctx context.Context, query string,
q.Set("api_key", apiKey)
q.Set("query", query)
q.Set("language", language)
q.Set("include_adult", "false")
q.Set("include_adult", "true")
if year > 0 {
q.Set("year", fmt.Sprintf("%d", year))
}
@@ -79,6 +79,12 @@ func (t *TMDbProvider) searchMovieCandidates(ctx context.Context, query string,
if err := t.getJSON(ctx, u, &p); err != nil {
return nil, err
}
if len(p.Results) == 0 && year > 0 {
// 移除年份限制重试一次,避免年份微小差异(如 2019 vs 2020)导致无搜索结果
q.Del("year")
u = base + "/search/movie?" + q.Encode()
_ = t.getJSON(ctx, u, &p)
}
if len(p.Results) == 0 {
return nil, nil
}
@@ -137,7 +143,7 @@ func (t *TMDbProvider) searchTVCandidates(ctx context.Context, query string, yea
apiKey := t.resolveAPIKey(ctx)
if apiKey == "" {
return nil, nil
return nil, errors.New("TMDb API Key 未配置,请先在「系统设置 → API配置」中填写")
}
base := t.resolveBaseURL(ctx)
@@ -145,7 +151,7 @@ func (t *TMDbProvider) searchTVCandidates(ctx context.Context, query string, yea
q.Set("api_key", apiKey)
q.Set("query", query)
q.Set("language", language)
q.Set("include_adult", "false")
q.Set("include_adult", "true")
if year > 0 {
q.Set("first_air_date_year", fmt.Sprintf("%d", year))
}
@@ -159,6 +165,12 @@ func (t *TMDbProvider) searchTVCandidates(ctx context.Context, query string, yea
if err := t.getJSON(ctx, u, &p); err != nil {
return nil, err
}
if len(p.Results) == 0 && year > 0 {
// 移除年份限制重试一次,避免年份微小差异导致无搜索结果
q.Del("first_air_date_year")
u = base + "/search/tv?" + q.Encode()
_ = t.getJSON(ctx, u, &p)
}
if len(p.Results) == 0 {
return nil, nil
}
+69 -18
View File
@@ -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),
}
+21 -5
View File
@@ -81,11 +81,19 @@ 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 }) =>
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 } : {}),
},
})
.then((r) => r.data),
@@ -105,8 +113,16 @@ export const libraryAPI = {
createPerSubfolder: (parentPath: string, type: string, coverURL = '') =>
api.post<{ libraries: Library[] }>('/libraries', { path: parentPath, type, cover_url: coverURL, create_per_subfolder: true }).then((r) => r.data),
update: (id: string, payload: { cover_url: string }) =>
api.patch<Library>(`/libraries/${id}`, payload).then((r) => r.data),
update: (
id: string,
payload: {
cover_url?: string
sort_order?: number | null
carousel_enabled?: boolean | null
},
) => api.patch<Library>(`/libraries/${id}`, payload).then((r) => r.data),
reorder: (ids: string[]) => api.put('/libraries/reorder', { ids }).then((r) => r.data),
remove: (id: string) => api.delete(`/libraries/${id}`).then((r) => r.data),
+57
View File
@@ -0,0 +1,57 @@
import { api } from './client'
import type { ScrapeQueueSnapshot } from '../types/scraper'
export interface EnqueueScrapeOptions {
episode_images?: boolean
episode_artwork?: boolean
refresh_matched?: boolean
include_matched?: boolean
}
export const scraperAPI = {
queue: (status?: string, page = 1, pageSize = 50) =>
api
.get<ScrapeQueueSnapshot>('/admin/scraper/queue', {
params: { status, page, page_size: pageSize },
})
.then((r) => r.data),
cancelTask: (id: string) =>
api.post(`/admin/scraper/queue/${id}/cancel`).then((r) => r.data),
retryTask: (id: string) =>
api.post(`/admin/scraper/queue/${id}/retry`).then((r) => r.data),
deleteTask: (id: string) =>
api.delete(`/admin/scraper/queue/${id}`).then((r) => r.data),
batchAction: (action: 'delete' | 'retry' | 'cancel', ids: string[]) =>
api
.post<{ affected: number; action: string }>('/admin/scraper/queue/batch', { action, ids })
.then((r) => r.data),
clearDone: () =>
api.post<{ deleted: number }>('/admin/scraper/queue/clear-done').then((r) => r.data),
clearFinished: () =>
api.post<{ deleted: number }>('/admin/scraper/queue/clear-finished').then((r) => r.data),
clearCanceled: () =>
api.post<{ deleted: number }>('/admin/scraper/queue/clear-canceled').then((r) => r.data),
retryFailed: () =>
api.post<{ retried: number }>('/admin/scraper/queue/retry-failed').then((r) => r.data),
cancelPending: () =>
api.post<{ canceled: number }>('/admin/scraper/queue/cancel-pending').then((r) => r.data),
enqueueLibrary: (libraryId: string, options?: EnqueueScrapeOptions) =>
api
.post<{ enqueued: number }>(`/admin/scraper/queue/enqueue-library/${libraryId}`, options ?? {})
.then((r) => r.data),
enqueueAll: (options?: EnqueueScrapeOptions) =>
api
.post<{ enqueued: number }>('/admin/scraper/queue/enqueue-all', options ?? {})
.then((r) => r.data),
}
+15 -3
View File
@@ -144,6 +144,12 @@ export const strmAPI = {
retryDownload: (id: string) =>
api.post(`/admin/strm/downloads/${id}/retry`).then((r) => r.data),
deleteDownload: (id: string) =>
api.delete(`/admin/strm/downloads/${id}`).then((r) => r.data),
batchActionDownloads: (action: 'delete' | 'retry' | 'cancel', ids: string[]) =>
api.post<{ affected: number; action: string }>('/admin/strm/downloads/batch', { action, ids }).then((r) => r.data),
clearDoneDownloads: () =>
api.post<{ deleted: number }>('/admin/strm/downloads/clear-done').then((r) => r.data),
@@ -169,12 +175,18 @@ export const strmAPI = {
cancelUpload: (id: string) =>
api.post(`/admin/strm/uploads/${id}/cancel`).then((r) => r.data),
retryUpload: (id: string) =>
api.post(`/admin/strm/uploads/${id}/retry`).then((r) => r.data),
deleteUpload: (id: string) =>
api.delete(`/admin/strm/uploads/${id}`).then((r) => r.data),
batchActionUploads: (action: 'delete' | 'retry' | 'cancel', ids: string[]) =>
api.post<{ affected: number; action: string }>('/admin/strm/uploads/batch', { action, ids }).then((r) => r.data),
cancelPendingUploads: () =>
api.post<{ canceled: number }>('/admin/strm/uploads/cancel-pending').then((r) => r.data),
clearCanceledUploads: () =>
api.post<{ deleted: number }>('/admin/strm/uploads/clear-canceled').then((r) => r.data),
retryUpload: (id: string) =>
api.post(`/admin/strm/uploads/${id}/retry`).then((r) => r.data),
}
+4
View File
@@ -33,6 +33,9 @@ const StrmDownloadQueuePage = lazy(() =>
const StrmUploadQueuePage = lazy(() =>
import('./pages/StrmQueuePage').then((m) => ({ default: m.StrmUploadQueuePage })),
)
const ScraperQueuePage = lazy(() =>
import('./pages/ScraperQueuePage').then((m) => ({ default: m.ScraperQueuePage })),
)
export type AppRoute = {
path?: string
@@ -62,5 +65,6 @@ export const appRoutes: AppRoute[] = [
{ path: 'strm', element: <StrmManagePage />, adminOnly: true },
{ path: 'strm/downloads', element: <StrmDownloadQueuePage />, adminOnly: true },
{ path: 'strm/uploads', element: <StrmUploadQueuePage />, adminOnly: true },
{ path: 'scraper/queue', element: <ScraperQueuePage />, adminOnly: true },
{ path: 'admin', element: <AdminPage />, adminOnly: true },
]
+2
View File
@@ -5,6 +5,7 @@ import {
FolderOpen,
Library,
Settings,
Sparkles,
Upload,
User,
Users,
@@ -22,6 +23,7 @@ export type LayoutNavItem = {
export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
{ to: '/profile', label: '个人资料', icon: User },
{ to: '/libraries?from=admin', label: '媒体库', icon: Library },
{ to: '/scraper/queue', label: '刮削队列', icon: Sparkles, adminOnly: true },
{ to: '/admin', label: '用户管理', icon: Users, adminOnly: true },
{ to: '/files', label: '文件管理', icon: FolderOpen, adminOnly: true },
{ to: '/settings', label: '系统设置', icon: Settings, adminOnly: true },
+2
View File
@@ -35,6 +35,8 @@ export function AdminLibraryPanel() {
onRemoveLibrary={libraryActions.removeLibrary}
onAddLibraryRoot={libraryActions.addLibraryRoot}
onEditLibraryCover={libraryActions.editLibraryCover}
onToggleCarousel={libraryActions.toggleCarouselLibrary}
onReorder={libraryActions.reorderLibraries}
/>
<LibraryStorageStats />
</div>
+188 -20
View File
@@ -1,5 +1,6 @@
import { useState, type MouseEvent, type ReactNode } from 'react'
import { Folder, Image, MoreVertical, Plus, Power, PowerOff, RefreshCw, Save, Trash2 } from 'lucide-react'
import { useEffect, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { Folder, GripVertical, Image, MoreVertical, Plus, Power, PowerOff, RefreshCw, Save, Trash2 } from 'lucide-react'
import { LocalDirBrowserDialog } from '../components/LocalDirBrowserDialog'
import type { Library, LibraryRoot } from '../types'
@@ -18,11 +19,15 @@ type LibraryTableProps = {
onRemoveLibrary: (library: Library) => void
onAddLibraryRoot: (library: Library, path?: string, name?: string) => void
onEditLibraryCover: (library: Library) => void
onToggleCarousel: (library: Library) => void
onReorder: (orderedLibs: Library[]) => void
}
export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) {
const [browsingRoot, setBrowsingRoot] = useState<{ libraryID: string; root: LibraryRoot; initialPath?: string } | null>(null)
const [addingRootLib, setAddingRootLib] = useState<Library | null>(null)
const [draggingId, setDraggingId] = useState<string | null>(null)
const dragOverId = useRef<string | null>(null)
const handleSelectRootPath = (selectedPath: string) => {
if (browsingRoot) {
@@ -40,15 +45,39 @@ export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) {
}
}
const handleReorder = (fromId: string, overId: string) => {
if (fromId === overId) return
const copy = [...libs]
const fromIndex = copy.findIndex((l) => l.id === fromId)
const overIndex = copy.findIndex((l) => l.id === overId)
if (fromIndex < 0 || overIndex < 0) return
const [moved] = copy.splice(fromIndex, 1)
copy.splice(overIndex, 0, moved)
actions.onReorder(copy)
}
const handleDrop = (e: DragEvent, overId: string) => {
e.preventDefault()
dragOverId.current = null
setDraggingId(null)
if (draggingId && overId !== draggingId) {
handleReorder(draggingId, overId)
}
}
return (
<>
<div className="glass-panel overflow-x-auto !p-3">
<table className="w-full min-w-[900px] text-left text-sm">
<table className="w-full min-w-[960px] text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="w-8 text-center" title="拖动排序">
<GripVertical size={13} className="mx-auto text-gray-300" />
</th>
<th className="w-28 py-2">名称</th>
<th>路径</th>
<th className="w-20">类型</th>
<th className="w-24">轮播</th>
<th className="w-12 text-right">操作</th>
</tr>
</thead>
@@ -59,6 +88,23 @@ export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) {
library={library}
onBrowseRoot={(root) => setBrowsingRoot({ libraryID: library.id, root, initialPath: root.path })}
onOpenAddRoot={() => setAddingRootLib(library)}
dragging={draggingId === library.id}
dragOver={dragOverId.current === library.id}
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'move'
dragOverId.current = null
setDraggingId(library.id)
}}
onDragOver={(e) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
if (dragOverId.current !== library.id) dragOverId.current = library.id
}}
onDragEnd={() => {
dragOverId.current = null
setDraggingId(null)
}}
onDrop={(e) => handleDrop(e, library.id)}
{...actions}
/>
))}
@@ -90,11 +136,37 @@ type LibraryTableRowProps = Omit<LibraryTableProps, 'libs'> & {
library: Library
onBrowseRoot: (root: LibraryRoot) => void
onOpenAddRoot: () => void
dragging?: boolean
dragOver?: boolean
onDragStart?: (e: DragEvent) => void
onDragOver?: (e: DragEvent) => void
onDragEnd?: () => void
onDrop?: (e: DragEvent) => void
}
function LibraryTableRow({ library, ...actions }: LibraryTableRowProps) {
function LibraryTableRow({ library, dragging, dragOver, onDragStart, onDragOver, onDragEnd, onDrop, ...actions }: LibraryTableRowProps) {
return (
<tr className="border-t border-gray-200">
<tr
draggable={false}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
onDrop={onDrop}
className={`border-t border-gray-200 transition-colors ${
dragging ? 'bg-primary-400/10 opacity-60' : dragOver ? 'bg-primary-400/5' : ''
}`}
>
<td className="py-2 text-center">
<button
type="button"
draggable
onDragStart={onDragStart}
className="inline-flex cursor-grab items-center justify-center rounded p-1 text-gray-400 transition hover:bg-gray-100 hover:text-brand-500 active:cursor-grabbing"
title="拖动以调整媒体库显示顺序"
>
<GripVertical size={16} />
</button>
</td>
<td className="py-2 pr-3 font-medium text-ink-600">
<div className="flex items-center gap-2">
{library.cover_url && <img src={library.cover_url} alt="" className="h-10 w-8 rounded object-cover" />}
@@ -105,6 +177,9 @@ function LibraryTableRow({ library, ...actions }: LibraryTableRowProps) {
<LibraryRootsCell library={library} {...actions} />
</td>
<td className="px-3 text-ink-100">{library.type}</td>
<td className="py-2 text-ink-100">
<CarouselToggle library={library} onToggleCarousel={actions.onToggleCarousel} />
</td>
<td className="py-2 text-right">
<LibraryActionsCell library={library} {...actions} />
</td>
@@ -112,6 +187,25 @@ function LibraryTableRow({ library, ...actions }: LibraryTableRowProps) {
)
}
function CarouselToggle({ library, onToggleCarousel }: { library: Library; onToggleCarousel: (library: Library) => void }) {
const on = Boolean(library.carousel_enabled)
return (
<button
type="button"
onClick={() => onToggleCarousel(library)}
className={`inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs font-semibold transition ${
on
? 'border-brand-500/50 bg-brand-500/10 text-brand-500'
: 'border-gray-300 bg-white text-ink-50 hover:border-gray-400'
}`}
title={on ? '已开启首页海报轮播(点击关闭)' : '未开启首页海报轮播(点击开启)'}
>
<span className={`h-3.5 w-3.5 rounded-full ${on ? 'bg-brand-500' : 'bg-gray-300'}`} />
{on ? '参与轮播' : '未参与'}
</button>
)
}
function LibraryRootsCell({ library, ...actions }: LibraryTableRowProps) {
const roots = library.roots?.length ? library.roots : [fallbackLibraryRoot(library)]
return (
@@ -255,18 +349,90 @@ function LibraryActionsCell({ library, onScanLibrary, onRemoveLibrary, onOpenAdd
}
function ActionMenu({ label, children }: { label: string; children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false)
const [coords, setCoords] = useState<{ top?: number; bottom?: number; right: number } | null>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const toggleMenu = (e: MouseEvent) => {
e.stopPropagation()
if (isOpen) {
setIsOpen(false)
return
}
if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
const estimatedHeight = 180
const openUpward = spaceBelow < estimatedHeight && rect.top > estimatedHeight
setCoords({
top: openUpward ? undefined : rect.bottom + 4,
bottom: openUpward ? window.innerHeight - rect.top + 4 : undefined,
right: window.innerWidth - rect.right,
})
setIsOpen(true)
}
}
useEffect(() => {
if (!isOpen) return
const handleClickOutside = (e: globalThis.MouseEvent) => {
if (
menuRef.current &&
!menuRef.current.contains(e.target as Node) &&
triggerRef.current &&
!triggerRef.current.contains(e.target as Node)
) {
setIsOpen(false)
}
}
const handleScrollOrResize = () => setIsOpen(false)
window.addEventListener('mousedown', handleClickOutside)
window.addEventListener('scroll', handleScrollOrResize, true)
window.addEventListener('resize', handleScrollOrResize)
return () => {
window.removeEventListener('mousedown', handleClickOutside)
window.removeEventListener('scroll', handleScrollOrResize, true)
window.removeEventListener('resize', handleScrollOrResize)
}
}, [isOpen])
return (
<details className="group relative inline-flex justify-end">
<summary
className="flex h-8 w-8 cursor-pointer list-none items-center justify-center rounded-lg border border-gray-200 bg-white text-ink-50 transition hover:border-primary-400/50 hover:text-brand-500 [&::-webkit-details-marker]:hidden"
<>
<button
ref={triggerRef}
type="button"
onClick={toggleMenu}
className={`inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border transition ${
isOpen
? 'border-brand-500 bg-brand-500/10 text-brand-500'
: 'border-gray-200 bg-white text-ink-50 hover:border-primary-400/50 hover:text-brand-500'
}`}
title={label}
>
<MoreVertical size={16} />
</summary>
<div className="absolute right-0 top-9 z-30 min-w-28 rounded-lg border border-gray-200 bg-white p-1 shadow-lg">
{children}
</div>
</details>
</button>
{isOpen &&
coords &&
createPortal(
<div
ref={menuRef}
style={{
position: 'fixed',
top: coords.top !== undefined ? `${coords.top}px` : undefined,
bottom: coords.bottom !== undefined ? `${coords.bottom}px` : undefined,
right: `${coords.right}px`,
zIndex: 99999,
}}
className="min-w-32 rounded-xl border border-gray-200/90 bg-white p-1.5 shadow-2xl backdrop-blur"
onClick={() => setIsOpen(false)}
>
{children}
</div>,
document.body,
)}
</>
)
}
@@ -283,17 +449,19 @@ function MenuButton({
onClick: () => void
children: ReactNode
}) {
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
event.currentTarget.closest('details')?.removeAttribute('open')
onClick()
}
return (
<button
className={`flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs transition ${
danger ? 'text-red-500 hover:bg-red-50' : 'text-ink-100 hover:bg-gray-50 hover:text-brand-500'
type="button"
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-xs font-medium transition ${
danger
? 'text-red-500 hover:bg-red-50'
: 'text-ink-100 hover:bg-gray-100 hover:text-brand-500'
}`}
title={label}
onClick={handleClick}
onClick={(e) => {
e.stopPropagation()
onClick()
}}
>
{icon}
<span>{children}</span>
+449
View File
@@ -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>
)
}
+9 -29
View File
@@ -13,21 +13,12 @@ import {
HomeLoadingState,
} from './HomePageSections'
const CAROUSEL_STORAGE_KEY = 'mmtl.home.carousel_libraries'
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
const asArray = <T,>(value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : [])
export function HomePage() {
const [libraries, setLibraries] = useState<Library[]>([])
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; items: Media[]; total: number }>>({})
const [selectedLibraryIds, setSelectedLibraryIds] = useState<string[]>(() => {
try {
const saved = localStorage.getItem(CAROUSEL_STORAGE_KEY)
return saved ? JSON.parse(saved) : []
} catch {
return []
}
})
const [history, setHistory] = useState<HistoryItem[]>([])
const [loading, setLoading] = useState(true)
@@ -46,18 +37,6 @@ export function HomePage() {
setLibraries(libs)
setHistory(hist.filter((h) => h && !h.completed && !!h.media))
// Set default selected libraries if none saved yet
setSelectedLibraryIds((current) => {
if (current.length > 0) return current
const allIds = libs.map((l) => l.id)
try {
localStorage.setItem(CAROUSEL_STORAGE_KEY, JSON.stringify(allIds))
} catch {
// ignore
}
return allIds
})
// Fetch media items for all libraries in parallel
const isSeriesType = (type?: string) => type === 'tv' || type === 'anime' || type === 'variety'
const results = await Promise.allSettled(
@@ -132,16 +111,18 @@ export function HomePage() {
return counts
}, [libraries, libraryData])
// Compute items to show in the Hero Carousel
// Compute items to show in the Hero Carousel. Only libraries flagged
// carousel_enabled contribute; a library's items array is empty for
// series-type libs (loaded via /series), so gate on cards instead.
const carouselItems = useMemo(() => {
const candidateMedia: Media[] = []
const effectiveSelectedIds =
selectedLibraryIds.length > 0 ? selectedLibraryIds : libraries.map((l) => l.id)
const effectiveSelectedIds = libraries
.filter((l) => l.carousel_enabled === true)
.map((l) => l.id)
for (const libId of effectiveSelectedIds) {
const data = libraryData[libId]
if (data && data.items.length > 0) {
// Pick representative items from series cards or raw items
if (data && data.cards.length > 0) {
for (const card of data.cards) {
if (hasArtwork(card.rep)) {
candidateMedia.push(card.rep)
@@ -150,9 +131,8 @@ export function HomePage() {
}
}
// Sort by artwork score / rating or shuffle / interleave
// Fallback to all loaded items with artwork
if (candidateMedia.length === 0) {
// Fallback to all loaded items with artwork
for (const lib of libraries) {
const data = libraryData[lib.id]
if (data) {
@@ -164,7 +144,7 @@ export function HomePage() {
}
return candidateMedia.slice(0, 10)
}, [selectedLibraryIds, libraries, libraryData])
}, [libraries, libraryData])
const empty =
!loading &&
+10 -19
View File
@@ -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)
}
+5 -1
View File
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, PlayCircle, RefreshCw, Tv } from 'lucide-react'
import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, PlayCircle, RefreshCw, Sparkles, Tv } from 'lucide-react'
import { imageURL } from '../api/client'
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
@@ -73,6 +73,10 @@ export function LibrariesHeader({
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
{repairing ? '正在启动…' : '全库修复+重刮'}
</button>
<Link to="/scraper/queue" className="btn-outline inline-flex items-center gap-1.5" title="查看正在进行的刮削任务与进度">
<Sparkles size={14} className="text-brand-500" />
<span>刮削队列</span>
</Link>
<button type="button" onClick={onManageLibraries} className="btn-outline">
管理媒体库
</button>
+17 -15
View File
@@ -113,21 +113,23 @@ export function LibraryPage() {
return (
<div className="space-y-6">
<LibraryPageHeader
library={library}
itemCount={isSeries ? seriesCards.length : total}
loadingAllText={loadingAllText}
scanProgress={scanProgress}
isAdmin={role === 'admin'}
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
scanning={scanning}
scraping={scraping}
repairing={repairing}
onScrapeEpisodeArtworkChange={setScrapeEpisodeArtwork}
onScan={handleScan}
onScrape={() => setScrapeDialogOpen(true)}
onRepairRescrape={handleRepairRescrape}
/>
{!selectedSeries && (
<LibraryPageHeader
library={library}
itemCount={isSeries ? seriesCards.length : total}
loadingAllText={loadingAllText}
scanProgress={scanProgress}
isAdmin={role === 'admin'}
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
scanning={scanning}
scraping={scraping}
repairing={repairing}
onScrapeEpisodeArtworkChange={setScrapeEpisodeArtwork}
onScan={handleScan}
onScrape={() => setScrapeDialogOpen(true)}
onRepairRescrape={handleRepairRescrape}
/>
)}
<LibraryMediaSections
isSeries={isSeries}
+4 -4
View File
@@ -55,10 +55,10 @@ export function LibraryPageHeader({
className="h-10"
/>
<button onClick={onScan} disabled={scanning} className="btn-outline">
{scanning ? '扫描中…' : '立即扫描'}
{scanning ? '扫描中…' : '扫描媒体库'}
</button>
<button onClick={onScrape} disabled={scraping} className="btn-outline">
{scraping ? '刮削中…' : '刮削元数据'}
<button onClick={onScrape} disabled={scraping} className="btn-outline" title="对整个媒体库执行刮削元数据">
{scraping ? '刮削中…' : '整库刮削元数据'}
</button>
<button
onClick={onRepairRescrape}
@@ -66,7 +66,7 @@ export function LibraryPageHeader({
className="btn-outline"
title="回填本库占位符外部 ID 并重刮,修正空 ID / 拆集问题"
>
{repairing ? '修复中…' : '修复+重刮本库'}
{repairing ? '修复中…' : '修复+重刮整库'}
</button>
</div>
)}
-305
View File
@@ -1,305 +0,0 @@
import { useEffect, useState } from 'react'
import {
Check,
CheckSquare,
Film,
FolderOpen,
HeartHandshake,
Layers,
Loader2,
Music,
Save,
SlidersHorizontal,
Square,
Tv,
} from 'lucide-react'
import toast from 'react-hot-toast'
import { adminAPI } from '../api/admin'
import { imageURL } from '../api/client'
import { libraryAPI } from '../api/library'
import type { Library, Setting } from '../types'
import { groupSeries, type SeriesCard } from '../utils/groupSeries'
import { getLibraryArtworks } from './librariesPageModel'
const CAROUSEL_STORAGE_KEY = 'mmtl.home.carousel_libraries'
const SETTING_KEY_CAROUSEL = 'home.carousel_libraries'
const TYPE_ICONS: Record<string, React.ReactNode> = {
movie: <Film size={20} />,
movies: <Film size={20} />,
tv: <Tv size={20} />,
series: <Tv size={20} />,
anime: <Layers size={20} />,
shows: <Tv size={20} />,
variety: <Tv size={20} />,
music: <Music size={20} />,
adult: <HeartHandshake size={20} />,
}
const TYPE_LABELS: Record<string, string> = {
movie: '电影',
movies: '电影',
tv: '剧集',
series: '剧集',
anime: '动漫',
shows: '综艺',
variety: '综艺',
music: '音乐',
adult: 'Adult',
}
export function LibrarySettingsPanel() {
const [libraries, setLibraries] = useState<Library[]>([])
const [libraryCards, setLibraryCards] = useState<Record<string, SeriesCard[]>>({})
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [dirty, setDirty] = useState(false)
useEffect(() => {
async function load() {
setLoading(true)
try {
const [libs, settings] = await Promise.all([
libraryAPI.list({ includeHidden: true }).catch(() => [] as Library[]),
adminAPI.listSettings().catch(() => [] as Setting[]),
])
const libList = Array.isArray(libs) ? libs : []
setLibraries(libList)
// 异步拉取各个媒体库的前几个条目用于封面展示
Promise.allSettled(
libList.map(async (lib) => {
const page = await libraryAPI.listMedia(lib.id, 1, 10)
const items = Array.isArray(page?.items) ? page.items : []
return { id: lib.id, cards: groupSeries(items) }
}),
).then((results) => {
const map: Record<string, SeriesCard[]> = {}
for (const r of results) {
if (r.status === 'fulfilled' && r.value) {
map[r.value.id] = r.value.cards
}
}
setLibraryCards(map)
})
// 优先读取系统配置,其次读取 localStorage,默认全选
const settingItem = Array.isArray(settings)
? settings.find((s) => s.key === SETTING_KEY_CAROUSEL)
: undefined
let initialIds: string[] | null = null
if (settingItem?.value) {
try {
initialIds = JSON.parse(settingItem.value)
} catch {
// ignore
}
}
if (!initialIds) {
try {
const saved = localStorage.getItem(CAROUSEL_STORAGE_KEY)
if (saved) initialIds = JSON.parse(saved)
} catch {
// ignore
}
}
if (Array.isArray(initialIds)) {
setSelectedIds(initialIds)
} else {
setSelectedIds(libList.map((l) => l.id))
}
} finally {
setLoading(false)
}
}
load()
}, [])
const toggleLibrary = (id: string) => {
setSelectedIds((prev) => {
const next = prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]
setDirty(true)
return next
})
}
const selectAll = () => {
setSelectedIds(libraries.map((l) => l.id))
setDirty(true)
}
const deselectAll = () => {
setSelectedIds([])
setDirty(true)
}
const handleSave = async () => {
setSaving(true)
try {
const jsonValue = JSON.stringify(selectedIds)
await adminAPI.updateSetting(SETTING_KEY_CAROUSEL, jsonValue)
try {
localStorage.setItem(CAROUSEL_STORAGE_KEY, jsonValue)
} catch {
// ignore
}
toast.success('海报轮播设置已保存')
setDirty(false)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'保存设置失败'
toast.error(msg)
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="flex justify-center py-12 text-ink-50">
<Loader2 className="animate-spin" />
</div>
)
}
return (
<div className="glass-panel space-y-6">
{/* 头部说明与快捷操作 */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between border-b border-gray-200/80 pb-4">
<div className="flex items-start gap-3">
<div className="rounded-xl border border-primary-400/40 bg-primary-400/10 p-2 text-brand-500 mt-0.5">
<SlidersHorizontal size={20} />
</div>
<div>
<h3 className="font-display text-lg font-bold text-ink-600">首页海报轮播设置</h3>
<p className="text-xs text-sand-500 mt-0.5">
选择参与首页顶部大图海报轮播推荐的媒体库。勾选的媒体库内容将轮流展示在系统首页顶部。
</p>
</div>
</div>
<div className="flex items-center gap-2 self-end sm:self-auto">
<button
type="button"
onClick={selectAll}
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-semibold text-ink-100 transition hover:border-primary-400/50 hover:text-brand-500"
>
<CheckSquare size={14} />
<span>全选</span>
</button>
<button
type="button"
onClick={deselectAll}
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-semibold text-ink-100 transition hover:border-primary-400/50 hover:text-brand-500"
>
<Square size={14} />
<span>清空</span>
</button>
</div>
</div>
{/* 媒体库列表卡片 */}
{libraries.length === 0 ? (
<div className="py-8 text-center text-xs text-sand-500">
暂无可用媒体库,请先添加媒体库后再配置海报轮播。
</div>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{libraries.map((lib) => {
const isSelected = selectedIds.includes(lib.id)
const cards = libraryCards[lib.id] || []
const artwork = getLibraryArtworks(lib, cards)
return (
<div
key={lib.id}
onClick={() => toggleLibrary(lib.id)}
className={`flex cursor-pointer items-center justify-between rounded-2xl border p-4 transition-all duration-200 select-none ${
isSelected
? 'border-brand-500/60 bg-primary-400/10 shadow-sm'
: 'border-gray-200 bg-white/70 hover:border-gray-300'
}`}
>
<div className="flex items-center gap-3 min-w-0">
<div
className={`grid h-12 w-16 shrink-0 gap-0.5 overflow-hidden rounded-xl bg-gray-100 shadow-inner transition-colors ${
artwork.length > 1 ? 'grid-cols-2' : 'grid-cols-1'
} ${
isSelected ? 'ring-2 ring-brand-500/30' : ''
}`}
>
{artwork.length > 0 ? (
artwork.map(({ src, version }, index) => (
<img
key={`${src}-${index}`}
src={imageURL(src, version)}
alt=""
className="h-full w-full object-cover"
referrerPolicy="no-referrer"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
))
) : (
<div className="flex h-full w-full items-center justify-center text-ink-50">
{TYPE_ICONS[lib.type] || <FolderOpen size={20} />}
</div>
)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="truncate font-display text-sm font-bold text-ink-600">
{lib.name}
</span>
<span className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold border border-gray-200 bg-gray-50 text-ink-50">
{TYPE_LABELS[lib.type] || '自定义'}
</span>
</div>
<p className="text-xs text-sand-500 mt-0.5">
{isSelected ? '已启用轮播' : '未参与轮播'}
</p>
</div>
</div>
<div
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border transition-colors ${
isSelected
? 'border-brand-500 bg-brand-500 text-white'
: 'border-gray-300 bg-white text-transparent'
}`}
>
<Check size={14} strokeWidth={3} />
</div>
</div>
)
})}
</div>
)}
{/* 底部保存按钮 */}
<div className="flex items-center justify-between pt-2 border-t border-gray-200/80">
<span className="text-xs text-sand-500">
已选择 {selectedIds.length} / {libraries.length} 个媒体库参与轮播
</span>
<button
type="button"
onClick={handleSave}
disabled={saving || !dirty}
className="neon-button disabled:opacity-50"
>
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
保存设置
</button>
</div>
</div>
)
}
+972
View File
@@ -0,0 +1,972 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import toast from 'react-hot-toast'
import {
AlertCircle,
Ban,
CheckCircle2,
Clock,
Copy,
ExternalLink,
Eye,
Film,
Image as ImageIcon,
Layers,
Loader2,
PlayCircle,
RefreshCw,
Search,
Sparkles,
Trash2,
Tv,
X,
} from 'lucide-react'
import { imageURL } from '../api/client'
import { scraperAPI } from '../api/scraper'
import type { ScrapeQueueSnapshot, ScrapeTask, ScrapeTaskStatus } from '../types/scraper'
import { apiErrorMessage, formatTime, taskStatusMeta } from './StrmManagePage'
const FILTERS: { key: 'all' | ScrapeTaskStatus; label: string; icon: typeof Clock; color: string }[] = [
{ key: 'all', label: '全部', icon: Sparkles, color: 'text-ink-600' },
{ key: 'pending', label: '排队中', icon: Clock, color: 'text-gray-500' },
{ key: 'running', label: '刮削中', icon: PlayCircle, color: 'text-brand-500' },
{ key: 'done', label: '已匹配', icon: CheckCircle2, color: 'text-emerald-500' },
{ key: 'failed', label: '未匹配/失败', icon: AlertCircle, color: 'text-rose-500' },
{ key: 'canceled', label: '已取消', icon: Ban, color: 'text-amber-500' },
]
const PROVIDER_LABELS: Record<string, string> = {
tmdb: 'TheMovieDB',
douban: '豆瓣 Douban',
bangumi: 'Bangumi 番组计划',
thetvdb: 'TheTVDB',
metatube: 'MetaTube',
}
const TYPE_ICONS: Record<string, ReactNode> = {
movie: <Film size={14} className="text-blue-500" />,
tv: <Tv size={14} className="text-purple-500" />,
anime: <Layers size={14} className="text-emerald-500" />,
adult: <Film size={14} className="text-rose-500" />,
}
const TYPE_LABELS: Record<string, string> = {
movie: '电影',
tv: '剧集',
anime: '动漫',
adult: 'Adult',
}
const PAGE_SIZE = 50
export function ScraperQueuePage() {
const [snapshot, setSnapshot] = useState<ScrapeQueueSnapshot | null>(null)
const [filter, setFilter] = useState<'all' | ScrapeTaskStatus>('all')
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [loading, setLoading] = useState(true)
const [isRefreshing, setIsRefreshing] = useState(false)
const [autoRefresh, setAutoRefresh] = useState(true)
const [batchBusy, setBatchBusy] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [detailTask, setDetailTask] = useState<ScrapeTask | null>(null)
const refresh = useCallback(
async (showLoading = false) => {
if (showLoading) setIsRefreshing(true)
try {
const status = filter === 'all' ? undefined : filter
const data = await scraperAPI.queue(status, page, PAGE_SIZE)
const tp = Math.max(1, Math.ceil((data.total ?? data.tasks.length) / PAGE_SIZE))
if (page > tp) {
setPage(tp)
return
}
setTotalPages(tp)
setSnapshot(data)
} catch {
/* keep existing */
} finally {
setLoading(false)
if (showLoading) setIsRefreshing(false)
}
},
[filter, page],
)
useEffect(() => {
refresh().catch(() => undefined)
}, [refresh])
useEffect(() => {
if (!autoRefresh) return
const timer = setInterval(() => {
refresh().catch(() => undefined)
}, 3000)
return () => clearInterval(timer)
}, [autoRefresh, refresh])
useEffect(() => {
setSelectedIds(new Set())
}, [filter, page])
const copyText = (text: string, label: string) => {
navigator.clipboard.writeText(text)
toast.success(`已复制${label}`)
}
// Row actions
const cancelTask = async (task: ScrapeTask) => {
try {
await scraperAPI.cancelTask(task.id)
toast.success('已取消刮削任务')
await refresh()
} catch (err) {
toast.error(apiErrorMessage(err))
}
}
const retryTask = async (task: ScrapeTask) => {
try {
await scraperAPI.retryTask(task.id)
toast.success('已重新推入刮削队列')
await refresh()
} catch (err) {
toast.error(apiErrorMessage(err))
}
}
const deleteTask = async (task: ScrapeTask) => {
try {
await scraperAPI.deleteTask(task.id)
toast.success('已删除刮削记录')
if (detailTask?.id === task.id) setDetailTask(null)
await refresh()
} catch (err) {
toast.error(apiErrorMessage(err))
}
}
// Batch actions
const runSelectedBatch = async (action: 'retry' | 'cancel' | 'delete') => {
const ids = Array.from(selectedIds)
if (ids.length === 0) return
const actionText = action === 'retry' ? '重新刮削' : action === 'cancel' ? '取消' : '删除'
if (action === 'delete' && !window.confirm(`确定删除选中的 ${ids.length} 条刮削记录?`)) return
if (action === 'cancel' && !window.confirm(`确定取消选中的 ${ids.length} 个刮削任务?`)) return
setBatchBusy(true)
try {
const res = await scraperAPI.batchAction(action, ids)
toast.success(`已成功${actionText} ${res.affected} 项`)
setSelectedIds(new Set())
await refresh()
} catch (err) {
toast.error(apiErrorMessage(err))
} finally {
setBatchBusy(false)
}
}
const runGlobalBatch = async (
action: () => Promise<{ deleted?: number; retried?: number; canceled?: number }>,
confirmMsg?: string,
) => {
if (confirmMsg && !window.confirm(confirmMsg)) return
setBatchBusy(true)
try {
const res = await action()
if (res.deleted !== undefined) toast.success(`已清空 ${res.deleted} 条记录`)
else if (res.retried !== undefined) toast.success(`已重新入队 ${res.retried} 个任务`)
else if (res.canceled !== undefined) toast.success(`已取消 ${res.canceled} 个任务`)
await refresh()
} catch (err) {
toast.error(apiErrorMessage(err))
} finally {
setBatchBusy(false)
}
}
// Enqueue all libraries
const handleEnqueueAll = async () => {
if (!window.confirm('确定将全库所有未匹配或需要更新的媒体重新推入刮削队列?')) return
setBatchBusy(true)
try {
const res = await scraperAPI.enqueueAll({ include_matched: false, refresh_matched: false, episode_images: true })
toast.success(`已将 ${res.enqueued} 个媒体项推入刮削队列`)
await refresh()
} catch (err) {
toast.error(apiErrorMessage(err))
} finally {
setBatchBusy(false)
}
}
// Filter and search
const tasks = snapshot?.tasks ?? []
const filteredTasks = useMemo(() => {
let list = tasks
if (filter !== 'all') {
list = list.filter((t) => t.status === filter)
}
if (search.trim()) {
const q = search.trim().toLowerCase()
list = list.filter(
(t) =>
t.media_title.toLowerCase().includes(q) ||
t.matched_title.toLowerCase().includes(q) ||
t.library_name.toLowerCase().includes(q) ||
t.media_path.toLowerCase().includes(q) ||
(t.error && t.error.toLowerCase().includes(q)),
)
}
return list
}, [tasks, filter, search])
const counts = snapshot?.counts
const activeTaskCount = (counts?.pending ?? 0) + (counts?.running ?? 0)
const failedCount = counts?.failed ?? 0
const allCurrentChecked =
filteredTasks.length > 0 && filteredTasks.every((t) => selectedIds.has(t.id))
const toggleSelectAll = () => {
if (allCurrentChecked) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(filteredTasks.map((t) => t.id)))
}
}
const toggleSelectRow = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
return (
<div className="space-y-6">
{/* 1. Header */}
<header className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary-400/30 bg-primary-400/10 text-brand-500 shadow-sm">
<Sparkles size={22} />
</div>
<div>
<div className="flex items-center gap-2">
<h1 className="font-display text-2xl font-bold text-ink-600 sm:text-3xl">刮削队列</h1>
{autoRefresh && (
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-300/40 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-semibold text-emerald-600">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500" />
实时同步
</span>
)}
</div>
<p className="text-xs text-sand-500 mt-0.5">
媒体元数据在线识别与海报/剧照下载进度(TMDb / 豆瓣 / Bangumi / TheTVDB)
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
disabled={batchBusy}
onClick={handleEnqueueAll}
className="inline-flex items-center gap-1.5 rounded-xl border border-brand-500/40 bg-brand-500/10 px-3 py-2 text-xs font-semibold text-brand-500 shadow-sm transition hover:bg-brand-500/20 disabled:opacity-50"
title="将所有媒体库未刮削媒体加入队列"
>
<Sparkles size={13} />
<span>全库重新刮削</span>
</button>
<button
type="button"
onClick={() => setAutoRefresh((v) => !v)}
className={`inline-flex items-center gap-1.5 rounded-xl border px-3 py-2 text-xs font-semibold transition ${
autoRefresh
? 'border-emerald-300/50 bg-emerald-50 text-emerald-700 hover:bg-emerald-100/70'
: 'border-gray-200 bg-white text-ink-50 hover:bg-gray-50'
}`}
title={autoRefresh ? '点击暂停自动刷新' : '点击开启 3 秒自动轮询'}
>
<Clock size={13} />
<span>自动刷新: {autoRefresh ? '开启' : '已暂停'}</span>
</button>
<button
type="button"
disabled={isRefreshing}
onClick={() => refresh(true)}
className="inline-flex items-center gap-1.5 rounded-xl border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-ink-100 shadow-sm transition hover:border-gray-300 hover:bg-gray-50"
title="手动刷新"
>
<RefreshCw size={13} className={isRefreshing ? 'animate-spin text-brand-500' : ''} />
<span>刷新</span>
</button>
<details className="relative inline-block">
<summary className="inline-flex cursor-pointer list-none items-center gap-1.5 rounded-xl border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-ink-100 shadow-sm transition hover:border-gray-300 hover:bg-gray-50 [&::-webkit-details-marker]:hidden">
<Trash2 size={13} className="text-sand-500" />
<span>批量操作</span>
</summary>
<div className="absolute right-0 top-10 z-30 min-w-44 rounded-xl border border-gray-200 bg-white p-1.5 shadow-xl backdrop-blur">
{failedCount > 0 && (
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(() => scraperAPI.retryFailed(), '确定重新入队所有失败任务?')
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-brand-500 hover:bg-brand-50"
>
<RefreshCw size={13} />
<span>重试所有失败 ({failedCount})</span>
</button>
)}
{activeTaskCount > 0 && (
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(() => scraperAPI.cancelPending(), '确定取消所有排队及进行中的刮削任务?')
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-amber-600 hover:bg-amber-50"
>
<Ban size={13} />
<span>取消所有进行中 ({activeTaskCount})</span>
</button>
)}
<div className="my-1 border-t border-gray-100" />
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(() => scraperAPI.clearDone(), '确定清空所有已匹配完成的记录?')
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-ink-100 hover:bg-gray-50"
>
<CheckCircle2 size={13} className="text-emerald-500" />
<span>清空已完成记录</span>
</button>
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(() => scraperAPI.clearCanceled(), '确定清空所有已取消的任务记录?')
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-ink-100 hover:bg-gray-50"
>
<Ban size={13} className="text-amber-500" />
<span>清空已取消记录</span>
</button>
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(() => scraperAPI.clearFinished(), '确定清空所有已完成、失败及取消的历史记录?')
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-rose-500 hover:bg-rose-50"
>
<Trash2 size={13} />
<span>清空全部历史记录</span>
</button>
</div>
</details>
</div>
</header>
{/* 2. Status Cards */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{FILTERS.map((item) => {
const count =
item.key === 'all'
? (counts?.pending ?? 0) +
(counts?.running ?? 0) +
(counts?.done ?? 0) +
(counts?.failed ?? 0) +
(counts?.canceled ?? 0)
: counts?.[item.key] ?? 0
const isActive = filter === item.key
const ItemIcon = item.icon
return (
<button
key={item.key}
type="button"
onClick={() => {
setFilter(item.key)
setPage(1)
}}
className={`flex flex-col justify-between rounded-2xl border p-3.5 text-left transition-all duration-200 select-none ${
isActive
? 'border-brand-500 bg-primary-400/10 shadow-sm ring-2 ring-brand-500/20'
: 'border-gray-200 bg-white/80 hover:border-gray-300 hover:bg-white'
}`}
>
<div className="flex items-center justify-between text-xs text-sand-500">
<span className="font-semibold">{item.label}</span>
<ItemIcon size={14} className={item.color} />
</div>
<div className="mt-2 flex items-baseline gap-1">
<span className={`font-display text-2xl font-black ${item.color}`}>{count}</span>
<span className="text-[10px] text-sand-400 font-medium">项</span>
</div>
</button>
)
})}
</div>
{/* 3. Search & Batch Actions */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative flex-1 max-w-md">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索媒体标题、匹配结果、媒体库或错误信息…"
className="h-9 w-full rounded-xl border border-gray-200 bg-white pl-9 pr-8 text-xs text-ink-600 placeholder:text-gray-400 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100/60"
/>
{search && (
<button
type="button"
onClick={() => setSearch('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded p-0.5 text-gray-400 hover:text-ink-600"
>
<X size={13} />
</button>
)}
</div>
{selectedIds.size > 0 && (
<div className="flex items-center gap-2 rounded-xl border border-brand-500/30 bg-primary-400/10 px-3 py-1.5 text-xs animate-in fade-in zoom-in-95">
<span className="font-bold text-brand-500">已选中 {selectedIds.size} 项</span>
<div className="h-3.5 w-px bg-brand-300/40 mx-1" />
<button
type="button"
disabled={batchBusy}
onClick={() => runSelectedBatch('retry')}
className="inline-flex items-center gap-1 rounded-lg border border-brand-500/40 bg-white px-2 py-1 font-semibold text-brand-500 hover:bg-brand-50 disabled:opacity-50"
>
<RefreshCw size={12} />
重试选中
</button>
<button
type="button"
disabled={batchBusy}
onClick={() => runSelectedBatch('cancel')}
className="inline-flex items-center gap-1 rounded-lg border border-amber-300 bg-white px-2 py-1 font-semibold text-amber-600 hover:bg-amber-50 disabled:opacity-50"
>
<Ban size={12} />
取消选中
</button>
<button
type="button"
disabled={batchBusy}
onClick={() => runSelectedBatch('delete')}
className="inline-flex items-center gap-1 rounded-lg border border-rose-300 bg-white px-2 py-1 font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-50"
>
<Trash2 size={12} />
删除选中
</button>
<button
type="button"
onClick={() => setSelectedIds(new Set())}
className="p-1 text-gray-400 hover:text-ink-600"
title="清空选择"
>
<X size={13} />
</button>
</div>
)}
</div>
{/* 4. Table */}
<div className="glass-panel overflow-hidden !p-0 shadow-sm">
{loading ? (
<div className="flex justify-center py-16 text-ink-50">
<Loader2 className="animate-spin text-brand-500" size={28} />
</div>
) : filteredTasks.length === 0 ? (
<div className="py-16 text-center text-xs text-sand-500">
{search
? '没有找到符合搜索条件的刮削任务'
: filter === 'all'
? '刮削队列为空,暂无进行或排队中的任务'
: `「${FILTERS.find((f) => f.key === filter)?.label}」状态下暂无任务`}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b border-gray-200/80 bg-gray-50/50 text-[11px] font-bold uppercase tracking-wider text-sand-500">
<tr>
<th className="w-10 px-3 py-3 text-center">
<input
type="checkbox"
checked={allCurrentChecked}
onChange={toggleSelectAll}
className="h-3.5 w-3.5 rounded border-gray-300 text-brand-500 focus:ring-brand-400 cursor-pointer"
title="全选 / 反选本页"
/>
</th>
<th className="px-3 py-3">媒体文件</th>
<th className="px-3 py-3">所属媒体库</th>
<th className="px-3 py-3">刮削匹配结果</th>
<th className="px-3 py-3">识别源</th>
<th className="px-3 py-3">状态</th>
<th className="px-3 py-3">时间</th>
<th className="px-3 py-3 text-right">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredTasks.map((task) => {
const status = taskStatusMeta(task.status)
const isSelected = selectedIds.has(task.id)
return (
<tr
key={task.id}
className={`transition-colors hover:bg-primary-400/5 ${
isSelected ? 'bg-primary-400/10' : ''
}`}
>
<td className="px-3 py-2.5 text-center">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelectRow(task.id)}
className="h-3.5 w-3.5 rounded border-gray-300 text-brand-500 focus:ring-brand-400 cursor-pointer"
/>
</td>
{/* Media title & path */}
<td className="max-w-[240px] px-3 py-2.5">
<div className="flex items-center gap-2">
{TYPE_ICONS[task.media_type] || <Film size={14} className="text-gray-400" />}
<div className="min-w-0">
<span
onClick={() => setDetailTask(task)}
className="cursor-pointer truncate font-medium text-ink-600 hover:text-brand-500 hover:underline block"
title={task.media_title}
>
{task.media_title}
</span>
<span className="truncate font-mono text-[10px] text-gray-400 block" title={task.media_path}>
{task.media_path}
</span>
</div>
</div>
</td>
{/* Library */}
<td className="px-3 py-2.5 text-xs text-ink-100 whitespace-nowrap">
<span className="rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-[11px] font-semibold text-ink-100">
{task.library_name || '媒体库'}
</span>
</td>
{/* Scraped matched result */}
<td className="max-w-[240px] px-3 py-2.5">
{task.matched_title ? (
<div className="flex items-center gap-2">
{task.poster_url ? (
<img
src={imageURL(task.poster_url)}
alt=""
className="h-10 w-7 rounded object-cover border border-gray-200 shrink-0"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
) : (
<div className="h-10 w-7 rounded bg-gray-100 flex items-center justify-center text-gray-400 shrink-0">
<ImageIcon size={12} />
</div>
)}
<div className="min-w-0">
<span className="font-bold text-ink-600 truncate block text-xs">
{task.matched_title}
</span>
{task.matched_year > 0 && (
<span className="text-[10px] text-gray-400">
{task.matched_year} 年
</span>
)}
</div>
</div>
) : (
<span className="text-xs text-sand-400 font-mono">
{task.status === 'pending' || task.status === 'running'
? '等待识别…'
: '未匹配到结果'}
</span>
)}
</td>
{/* Provider */}
<td className="px-3 py-2.5 text-xs text-ink-100 whitespace-nowrap">
{task.provider ? (
<span className="rounded bg-brand-500/10 border border-brand-500/20 px-1.5 py-0.5 text-[10px] font-bold text-brand-500">
{PROVIDER_LABELS[task.provider] ?? task.provider}
</span>
) : (
<span className="text-gray-300 text-xs">—</span>
)}
</td>
{/* Status & Error */}
<td className="px-3 py-2.5">
<div className="flex flex-col gap-0.5">
<span
className={`inline-flex w-fit items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold ${status.cls}`}
>
{task.status === 'running' && (
<Loader2 size={10} className="animate-spin" />
)}
{task.status === 'done'
? '已匹配'
: task.status === 'failed'
? '未匹配'
: status.label}
</span>
{task.error && (
<span
onClick={() => setDetailTask(task)}
className="cursor-pointer truncate max-w-[180px] text-[10px] text-rose-500 hover:underline"
title={task.error}
>
{task.error}
</span>
)}
</div>
</td>
{/* Time */}
<td className="px-3 py-2.5 text-xs text-ink-50 whitespace-nowrap">
{formatTime(task.created_at)}
</td>
{/* Actions */}
<td className="px-3 py-2.5 text-right whitespace-nowrap">
<div className="flex items-center justify-end gap-1">
<button
type="button"
onClick={() => setDetailTask(task)}
className="rounded-lg p-1.5 text-gray-400 transition hover:bg-gray-100 hover:text-ink-600"
title="查看详情"
>
<Eye size={13} />
</button>
{(task.status === 'pending' || task.status === 'running') && (
<button
type="button"
onClick={() => cancelTask(task)}
className="rounded-lg border border-amber-200 px-2 py-1 text-xs font-semibold text-amber-600 transition hover:bg-amber-50"
title="取消刮削"
>
取消
</button>
)}
{(task.status === 'failed' ||
task.status === 'canceled' ||
task.status === 'done') && (
<button
type="button"
onClick={() => retryTask(task)}
className="rounded-lg border border-primary-400/50 bg-primary-400/5 px-2 py-1 text-xs font-semibold text-brand-500 transition hover:bg-primary-400/15"
title="重新刮削"
>
重刮
</button>
)}
{(task.status === 'done' ||
task.status === 'failed' ||
task.status === 'canceled') && (
<button
type="button"
onClick={() => deleteTask(task)}
className="rounded-lg p-1.5 text-gray-400 transition hover:bg-rose-50 hover:text-rose-500"
title="删除记录"
>
<Trash2 size={13} />
</button>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{/* 5. Pagination */}
{(snapshot?.total ?? 0) > 0 && (
<div className="flex items-center justify-between border-t border-gray-200/80 bg-gray-50/40 px-4 py-3">
<span className="text-xs text-sand-500">
共 {snapshot?.total ?? 0} 条 · 第 {page} / {totalPages} 页
</span>
<div className="flex items-center gap-1.5">
<button
type="button"
disabled={page <= 1 || loading}
onClick={() => setPage((p) => Math.max(1, p - 1))}
className="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-ink-100 transition hover:bg-gray-50 disabled:opacity-40"
>
上一页
</button>
<button
type="button"
disabled={page >= totalPages || loading}
onClick={() => setPage((p) => p + 1)}
className="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-ink-100 transition hover:bg-gray-50 disabled:opacity-40"
>
下一页
</button>
</div>
</div>
)}
</div>
{/* 6. Task Detail Modal */}
{detailTask && (
<ScrapeDetailModal
task={detailTask}
onClose={() => setDetailTask(null)}
onRetry={retryTask}
onCancel={cancelTask}
onDelete={deleteTask}
onCopy={copyText}
/>
)}
</div>
)
}
function ScrapeDetailModal({
task,
onClose,
onRetry,
onCancel,
onDelete,
onCopy,
}: {
task: ScrapeTask
onClose: () => void
onRetry: (t: ScrapeTask) => void
onCancel: (t: ScrapeTask) => void
onDelete: (t: ScrapeTask) => void
onCopy: (text: string, label: string) => void
}) {
const status = taskStatusMeta(task.status)
return (
<div
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
onClick={onClose}
>
<div
className="w-full max-w-xl rounded-3xl border border-gray-200 bg-white shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
<div className="flex items-center gap-2">
<Sparkles size={16} className="text-brand-500" />
<h3 className="font-display text-base font-bold text-ink-600">刮削任务详情</h3>
</div>
<button
type="button"
onClick={onClose}
className="rounded-xl p-1 text-gray-400 hover:bg-gray-100 hover:text-ink-600 transition"
>
<X size={18} />
</button>
</div>
<div className="space-y-4 p-6 max-h-[70vh] overflow-y-auto text-xs">
{/* Matched Poster / Info Banner */}
{task.matched_title ? (
<div className="flex gap-4 rounded-2xl border border-brand-500/20 bg-primary-400/5 p-4">
{task.poster_url && (
<img
src={imageURL(task.poster_url)}
alt=""
className="h-28 w-20 rounded-xl object-cover border border-brand-500/30 shadow-md shrink-0"
/>
)}
<div className="space-y-1.5 min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="rounded bg-brand-500 px-2 py-0.5 text-[10px] font-bold text-white uppercase">
已匹配
</span>
{task.provider && (
<span className="rounded border border-gray-200 bg-white px-2 py-0.5 text-[10px] font-semibold text-ink-600">
{PROVIDER_LABELS[task.provider] ?? task.provider}
</span>
)}
</div>
<h4 className="font-display text-base font-extrabold text-ink-600 truncate">
{task.matched_title}
</h4>
<div className="flex items-center gap-3 text-sand-500 text-[11px]">
{task.matched_year > 0 && <span>年份:{task.matched_year}</span>}
<span>类型:{TYPE_LABELS[task.media_type] ?? task.media_type}</span>
</div>
{task.media_id && (
<Link
to={`/media/${task.media_id}`}
target="_blank"
className="inline-flex items-center gap-1 text-brand-500 font-semibold hover:underline pt-1"
>
<span>在媒体详情中查看</span>
<ExternalLink size={11} />
</Link>
)}
</div>
</div>
) : null}
{/* Media Info Box */}
<div className="rounded-2xl border border-gray-100 bg-gray-50/70 p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sand-500 font-medium">原始媒体标题</span>
<span className="font-bold text-ink-600 select-all">{task.media_title}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sand-500 font-medium">所属媒体库</span>
<span className="font-medium text-ink-100">{task.library_name}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sand-500 font-medium">媒体库类型</span>
<span className="font-medium text-ink-100">
{TYPE_LABELS[task.media_type] ?? task.media_type}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sand-500 font-medium">当前状态</span>
<span
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${status.cls}`}
>
{task.status === 'done' ? '已匹配' : task.status === 'failed' ? '未匹配' : status.label}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sand-500 font-medium">剧照/海报刮削</span>
<span className="font-medium text-ink-100">
{task.episode_images ? '开启' : '关闭'}
</span>
</div>
</div>
{/* File path */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sand-500 font-medium">
<span>磁盘文件路径</span>
<button
type="button"
onClick={() => onCopy(task.media_path, '文件路径')}
className="inline-flex items-center gap-1 text-brand-500 hover:underline"
>
<Copy size={11} /> 复制
</button>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3 font-mono text-[11px] text-ink-600 break-all select-all">
{task.media_path}
</div>
</div>
{/* Error Message Box */}
{task.error && (
<div className="space-y-2">
<div className="flex items-center justify-between text-rose-500 font-medium">
<span className="flex items-center gap-1">
<AlertCircle size={13} /> 刮削未匹配 / 异常详情
</span>
<button
type="button"
onClick={() => onCopy(task.error, '错误日志')}
className="inline-flex items-center gap-1 text-rose-500 hover:underline"
>
<Copy size={11} /> 复制日志
</button>
</div>
<div className="rounded-xl border border-rose-200 bg-rose-50/60 p-3 font-mono text-[11px] text-rose-700 break-all select-all whitespace-pre-wrap">
{task.error}
</div>
</div>
)}
{/* Timeline */}
<div className="grid grid-cols-2 gap-3 pt-2 text-[11px] text-sand-500 border-t border-gray-100">
<div>入队时间:{formatTime(task.created_at)}</div>
{task.started_at && <div>开始刮削:{formatTime(task.started_at)}</div>}
{task.finished_at && <div>完成时间:{formatTime(task.finished_at)}</div>}
</div>
</div>
{/* Footer Actions */}
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4 bg-gray-50/50">
<div>
{(task.status === 'done' ||
task.status === 'failed' ||
task.status === 'canceled') && (
<button
type="button"
onClick={() => onDelete(task)}
className="inline-flex items-center gap-1 rounded-xl border border-rose-200 bg-white px-3 py-2 text-xs font-semibold text-rose-500 hover:bg-rose-50 transition"
>
<Trash2 size={13} />
删除记录
</button>
)}
</div>
<div className="flex items-center gap-2">
{(task.status === 'pending' || task.status === 'running') && (
<button
type="button"
onClick={() => onCancel(task)}
className="inline-flex items-center gap-1 rounded-xl border border-amber-200 bg-white px-4 py-2 text-xs font-semibold text-amber-600 hover:bg-amber-50 transition"
>
<Ban size={13} />
取消任务
</button>
)}
<button
type="button"
onClick={() => onRetry(task)}
className="neon-button !py-2 !px-4 text-xs font-semibold"
>
<RefreshCw size={13} />
重新刮削
</button>
<button
type="button"
onClick={onClose}
className="rounded-xl border border-gray-200 bg-white px-4 py-2 text-xs font-semibold text-ink-100 hover:bg-gray-50 transition"
>
关闭
</button>
</div>
</div>
</div>
</div>
)
}
+2 -2
View File
@@ -8,7 +8,7 @@ import { libraryAPI } from '../api/library'
import type { Library, Setting } from '../types'
import { APIConfigsPanel } from '../components/APIConfigsPanel'
import { AdultSettingsPanel } from './AdultSettingsPanel'
import { LibrarySettingsPanel } from './LibrarySettingsPanel'
import { DatabaseSettingsPanel } from './DatabaseSettingsPanel'
import { RecognitionWordsPanel } from './RecognitionWordsPanel'
import { SettingRow } from './SettingsRow'
import { ALL_KEYS, GROUPS } from './settingsGroups'
@@ -168,7 +168,7 @@ export function SettingsPage() {
{!loading && (
<div className="space-y-4">
{group.key === 'library' && <LibrarySettingsPanel />}
{group.key === 'database' && <DatabaseSettingsPanel />}
{group.key === 'api-configs' && <APIConfigsPanel />}
{group.key === 'recognition-words' && <RecognitionWordsPanel />}
{group.key === 'adult' && <AdultSettingsPanel />}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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[]
}
-8
View File
@@ -1,8 +0,0 @@
import type { SettingGroup } from './settingsGroupTypes'
export const librarySettingsGroup: SettingGroup = {
key: 'library',
label: '媒体库',
description: '配置首页海报轮播等媒体库展示与偏好',
items: [],
}
+8 -2
View File
@@ -2,15 +2,21 @@ import { adultSettingsGroup } from './settingsGroupAccess'
import { apiConfigsSettingsGroup } from './settingsGroupAPIConfigs'
import { danmakuSettingsGroup } from './settingsGroupDanmaku'
import { generalSettingsGroup } from './settingsGroupGeneral'
import { librarySettingsGroup } from './settingsGroupLibrary'
import { recognitionWordsSettingsGroup } from './settingsGroupRecognitionWords'
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,
librarySettingsGroup,
databaseSettingsGroup,
apiConfigsSettingsGroup,
recognitionWordsSettingsGroup,
danmakuSettingsGroup,
+13 -1
View File
@@ -164,6 +164,18 @@ function useLibraryActions(refresh: () => Promise<void>) {
else toast.success(`扫描完成,新增 ${result.added},更新 ${result.updated ?? 0}`)
}
const toggleCarouselLibrary = async (library: Library) => {
const next = !Boolean(library.carousel_enabled)
await libraryAPI.update(library.id, { carousel_enabled: next })
toast.success(next ? `「${library.name}」已加入首页轮播` : `「${library.name}」已移出首页轮播`)
await refresh()
}
const reorderLibraries = async (orderedLibs: Library[]) => {
await libraryAPI.reorder(orderedLibs.map((l) => l.id))
await refresh()
}
const removeLibrary = async (library: Library) => {
if (!(await confirmAction({ title: '删除媒体库', message: `确定删除「${library.name}」?`, confirmText: '删除' }))) return
await libraryAPI.remove(library.id)
@@ -191,5 +203,5 @@ function useLibraryActions(refresh: () => Promise<void>) {
await refresh()
}
return { scanLibrary, removeLibrary, addLibraryRoot, editLibraryCover }
return { scanLibrary, removeLibrary, addLibraryRoot, editLibraryCover, toggleCarouselLibrary, reorderLibraries }
}
+2
View File
@@ -16,6 +16,8 @@ export interface Library {
type: string
cover_url?: string
enabled: boolean
sort_order?: number
carousel_enabled?: boolean
roots?: LibraryRoot[]
created_at: string
updated_at: string
+40
View File
@@ -0,0 +1,40 @@
export type ScrapeTaskStatus = 'pending' | 'running' | 'done' | 'failed' | 'canceled'
export interface ScrapeTask {
id: string
media_id: string
library_id: string
library_name: string
media_title: string
media_path: string
media_type: string
provider: string
matched_title: string
matched_year: number
poster_url: string
backdrop_url: string
status: ScrapeTaskStatus
error: string
retry_count: number
episode_images: boolean
refresh_matched: boolean
created_at: string
started_at?: string | null
finished_at?: string | null
}
export interface ScrapeQueueCounts {
pending: number
running: number
done: number
failed: number
canceled: number
}
export interface ScrapeQueueSnapshot {
counts: ScrapeQueueCounts
tasks: ScrapeTask[]
total: number
page: number
page_size: number
}