Merge pull request #49 from truewhile/feat/media-maintenance-and-diagnostics

完善媒体维护、备份恢复与播放诊断
This commit is contained in:
truewhile
2026-10-20 22:04:10 +08:00
committed by GitHub
53 changed files with 2049 additions and 238 deletions
+3
View File
@@ -79,6 +79,9 @@ func (a *application) start() error {
} }
} }
if err := database.ApplyPendingSQLiteRestore(context.Background(), a.cfg, a.logger); err != nil {
return fmt.Errorf("apply pending database restore: %w", err)
}
db, err := database.Open(a.cfg, a.logger) db, err := database.Open(a.cfg, a.logger)
if err != nil { if err != nil {
a.logger.Error("database open failed", zap.Error(err)) a.logger.Error("database open failed", zap.Error(err))
+189
View File
@@ -0,0 +1,189 @@
package database
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
)
var sqliteRestoreMu sync.Mutex
func sqliteRestorePath(cfg *config.Config) (string, error) {
if cfg == nil || strings.TrimSpace(cfg.Database.DBPath) == "" || cfg.Database.DBPath == ":memory:" || strings.HasPrefix(cfg.Database.DBPath, "file:") {
return "", errors.New("恢复需要配置有效的 SQLite 数据库文件路径")
}
return filepath.Abs(cfg.Database.DBPath)
}
func sqliteRestoreDSN(path, mode string) string {
u := url.URL{Scheme: "file", Path: filepath.ToSlash(path)}
q := url.Values{"mode": {mode}, "_pragma": {"busy_timeout(5000)"}}
u.RawQuery = q.Encode()
return u.String()
}
func validateSQLiteRestore(ctx context.Context, path string) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return errors.New("备份必须是常规文件")
}
db, err := sql.Open("sqlite", sqliteRestoreDSN(path, "ro"))
if err != nil {
return err
}
defer db.Close()
var result string
if err := db.QueryRowContext(ctx, "PRAGMA quick_check").Scan(&result); err != nil {
return fmt.Errorf("SQLite 备份校验失败: %w", err)
}
if result != "ok" {
return fmt.Errorf("SQLite 备份校验失败: %s", result)
}
var tables int
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('users', 'media', 'settings')").Scan(&tables); err != nil {
return err
}
if tables == 0 {
return errors.New("备份不包含 MeBox 数据表")
}
return nil
}
// StageSQLiteRestore leaves the live database and its WAL untouched. The
// validated copy is applied on the next startup, before any database is opened.
func StageSQLiteRestore(ctx context.Context, cfg *config.Config, source string) error {
sqliteRestoreMu.Lock()
defer sqliteRestoreMu.Unlock()
dbPath, err := sqliteRestorePath(cfg)
if err != nil {
return err
}
pending := dbPath + ".restore-pending"
if _, err := os.Lstat(pending); err == nil {
return errors.New("已有等待恢复的备份,请重启 MeBox 后再操作")
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err := validateSQLiteRestore(ctx, source); err != nil {
return err
}
in, err := os.Open(source)
if err != nil {
return err
}
defer in.Close()
out, err := os.CreateTemp(filepath.Dir(dbPath), ".mebox-restore-*")
if err != nil {
return err
}
tmp := out.Name()
defer os.Remove(tmp)
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return err
}
if err := out.Sync(); err != nil {
_ = out.Close()
return err
}
if err := out.Close(); err != nil {
return err
}
if err := validateSQLiteRestore(ctx, tmp); err != nil {
return err
}
return os.Rename(tmp, pending)
}
// ApplyPendingSQLiteRestore must run before database.Open and before workers or
// HTTP listeners start. The previous database is retained alongside the file.
func ApplyPendingSQLiteRestore(ctx context.Context, cfg *config.Config, log *zap.Logger) error {
if cfg == nil {
return nil
}
dialect := normalizeDatabaseType(cfg.Database.Type)
if dialect == "auto" {
dialect = effectiveAutoDatabaseType(cfg)
}
if dialect != "sqlite" {
return nil
}
sqliteRestoreMu.Lock()
defer sqliteRestoreMu.Unlock()
dbPath, err := sqliteRestorePath(cfg)
if err != nil {
return err
}
pending := dbPath + ".restore-pending"
if _, err := os.Lstat(pending); errors.Is(err, os.ErrNotExist) {
return nil
} else if err != nil {
return err
}
if err := validateSQLiteRestore(ctx, pending); err != nil {
return err
}
previous := ""
if _, err := os.Lstat(dbPath); err == nil {
// A clean checkpoint prevents an old WAL from being replayed into the
// replacement database. A busy checkpoint leaves all files untouched.
live, err := sql.Open("sqlite", sqliteRestoreDSN(dbPath, "rw"))
if err != nil {
return err
}
var busy, pages, copied int
err = live.QueryRowContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)").Scan(&busy, &pages, &copied)
closeErr := live.Close()
if err != nil {
return err
}
if closeErr != nil {
return closeErr
}
if busy != 0 {
return errors.New("SQLite 数据库仍被占用,恢复已保留等待下次启动")
}
old, err := os.CreateTemp(filepath.Dir(dbPath), filepath.Base(dbPath)+".before-restore-*")
if err != nil {
return err
}
previous = old.Name()
if err := old.Close(); err != nil {
return err
}
if err := os.Remove(previous); err != nil {
return err
}
if err := os.Rename(dbPath, previous); err != nil {
return err
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.Rename(pending, dbPath); err != nil {
if previous != "" {
if rollbackErr := os.Rename(previous, dbPath); rollbackErr != nil {
return fmt.Errorf("恢复失败: %w;回退失败: %v,原数据库保留于 %s", err, rollbackErr, previous)
}
}
return err
}
if log != nil {
log.Warn("SQLite backup restored before startup", zap.String("previous_database", previous))
}
return nil
}
+104
View File
@@ -0,0 +1,104 @@
package database
import (
"database/sql"
"os"
"path/filepath"
"testing"
"github.com/truewhile/MeBox/internal/config"
)
func TestSQLiteRestoreStagesWithoutChangingLiveDatabase(t *testing.T) {
root := t.TempDir()
cfg := &config.Config{}
cfg.Database.Type = "sqlite"
cfg.Database.DBPath = filepath.Join(root, "live.db")
live, err := sql.Open("sqlite", sqliteRestoreDSN(cfg.Database.DBPath, "rwc"))
if err != nil {
t.Fatal(err)
}
defer live.Close()
for _, statement := range []string{
"PRAGMA journal_mode=WAL", "CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)",
"INSERT INTO settings VALUES ('state', 'backup')",
} {
if _, err := live.Exec(statement); err != nil {
t.Fatal(err)
}
}
source := filepath.Join(root, "backup.db")
if _, err := live.Exec("VACUUM INTO ?", source); err != nil {
t.Fatal(err)
}
if _, err := live.Exec("UPDATE settings SET value = 'live'"); err != nil {
t.Fatal(err)
}
if err := StageSQLiteRestore(t.Context(), cfg, source); err != nil {
t.Fatal(err)
}
var value string
if err := live.QueryRow("SELECT value FROM settings").Scan(&value); err != nil || value != "live" {
t.Fatalf("staging changed live database: value=%q err=%v", value, err)
}
if _, err := live.Exec("UPDATE settings SET value = 'after-stage'"); err != nil {
t.Fatalf("database could not write after staging: %v", err)
}
if err := StageSQLiteRestore(t.Context(), cfg, source); err == nil {
t.Fatal("second staging should not replace the pending restore")
}
if err := os.Remove(source); err != nil {
t.Fatal(err)
}
if err := live.Close(); err != nil {
t.Fatal(err)
}
if err := ApplyPendingSQLiteRestore(t.Context(), cfg, nil); err != nil {
t.Fatal(err)
}
restored, err := sql.Open("sqlite", sqliteRestoreDSN(cfg.Database.DBPath, "ro"))
if err != nil {
t.Fatal(err)
}
defer restored.Close()
if err := restored.QueryRow("SELECT value FROM settings").Scan(&value); err != nil || value != "backup" {
t.Fatalf("restore failed: value=%q err=%v", value, err)
}
previous, err := filepath.Glob(cfg.Database.DBPath + ".before-restore-*")
if err != nil || len(previous) != 1 {
t.Fatalf("previous database not preserved: files=%v err=%v", previous, err)
}
old, err := sql.Open("sqlite", sqliteRestoreDSN(previous[0], "ro"))
if err != nil {
t.Fatal(err)
}
defer old.Close()
if err := old.QueryRow("SELECT value FROM settings").Scan(&value); err != nil || value != "after-stage" {
t.Fatalf("old database lost latest writes: value=%q err=%v", value, err)
}
if err := ApplyPendingSQLiteRestore(t.Context(), cfg, nil); err != nil {
t.Fatalf("repeat startup: %v", err)
}
}
func TestSQLiteRestoreRejectsCorruptAndSymlinkBackups(t *testing.T) {
root := t.TempDir()
cfg := &config.Config{}
cfg.Database.DBPath = filepath.Join(root, "live.db")
source := filepath.Join(root, "corrupt.db")
if err := os.WriteFile(source, []byte("not a SQLite database"), 0o600); err != nil {
t.Fatal(err)
}
if err := StageSQLiteRestore(t.Context(), cfg, source); err == nil {
t.Fatal("corrupt backup accepted")
}
link := filepath.Join(root, "link.db")
if err := os.Symlink(source, link); err == nil {
if err := StageSQLiteRestore(t.Context(), cfg, link); err == nil {
t.Fatal("symlink backup accepted")
}
}
if _, err := os.Stat(cfg.Database.DBPath + ".restore-pending"); !os.IsNotExist(err) {
t.Fatalf("failed validation left pending data: %v", err)
}
}
+18
View File
@@ -0,0 +1,18 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
func backgroundTasksHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc == nil || svc.Tasks == nil {
c.JSON(http.StatusOK, service.TaskSnapshot{Active: []service.BackgroundTask{}, Recent: []service.BackgroundTask{}})
return
}
c.JSON(http.StatusOK, svc.Tasks.Snapshot())
}
}
+18 -1
View File
@@ -3,6 +3,7 @@ package handler
import ( import (
"net/http" "net/http"
"path/filepath"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -41,12 +42,28 @@ func deleteBackupHandler(svc *service.Container) gin.HandlerFunc {
} }
} }
func downloadBackupHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
filename := c.Query("filename")
path, err := svc.Backup.ResolvePath(filename)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.FileAttachment(path, filepath.Base(path))
}
}
func restoreBackupHandler(svc *service.Container) gin.HandlerFunc { func restoreBackupHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
if err := svc.Backup.Restore(c.Request.Context(), c.Query("filename")); err != nil { if err := svc.Backup.Restore(c.Request.Context(), c.Query("filename")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{"message": "restored — restart the server to apply"}) message := "数据库已恢复,请重启 MeBox 以刷新运行时配置"
if svc.Repo.DB.Dialector.Name() == "sqlite" {
message = "备份已校验并暂存,请重启 MeBox 后应用恢复"
}
c.JSON(http.StatusOK, gin.H{"message": message, "restart_required": true})
} }
} }
+146 -105
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"math" "math"
"net/http" "net/http"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -654,6 +655,9 @@ func paginateSlice[T any](items []T, page, size int) []T {
if len(items) == 0 { if len(items) == 0 {
return []T{} return []T{}
} }
if page-1 > len(items)/size {
return []T{}
}
start := (page - 1) * size start := (page - 1) * size
if start >= len(items) { if start >= len(items) {
return []T{} return []T{}
@@ -668,125 +672,162 @@ func paginateSlice[T any](items []T, page, size int) []T {
func searchMediaHandler(svc *service.Container) gin.HandlerFunc { func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
q := c.Query("q") query := strings.TrimSpace(c.Query("q"))
visibility := mediaVisibilityForRequest(c, svc) visibility := mediaVisibilityForRequest(c, svc)
groupVersions := c.DefaultQuery("group_versions", "1") != "0" filter := service.MediaSearchFilter{
LibraryID: strings.TrimSpace(c.Query("library_id")),
fetchRemote := func(limit int) []model.Media { YearMin: boundedSearchYear(c.Query("year_min")),
if svc.EmbyRemote == nil || strings.TrimSpace(q) == "" { YearMax: boundedSearchYear(c.Query("year_max")),
return nil }
for _, genre := range c.QueryArray("genre") {
if genre = strings.TrimSpace(genre); genre != "" && len(filter.Genres) < 8 {
filter.Genres = append(filter.Genres, genre)
}
}
if filter.YearMin > 0 && filter.YearMax > 0 && filter.YearMin > filter.YearMax {
filter.YearMin, filter.YearMax = filter.YearMax, filter.YearMin
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
if size < 1 {
size = 50
} else if size > 2000 {
size = 2000
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit < 1 {
limit = 50
} else if limit > 50000 {
limit = 50000
}
paginated := c.Query("page") != "" || c.Query("page_size") != ""
sortBy := normalizeMediaSearchSort(c.Query("sort"))
// Sorting and grouping must operate on the same candidate set before
// either a page size or the caller's output limit is applied.
items, err := svc.Media.SearchMediaVisibleFiltered(ctx, query, 50000, visibility, filter)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if svc.EmbyRemote != nil && query != "" && (filter.LibraryID == "" || service.IsEmbyRemoteID(filter.LibraryID)) {
remote, _ := svc.EmbyRemote.RemoteSearchMedia(ctx, query, 50000, visibility)
for _, item := range remote {
if remoteSearchMatchesFilter(item, filter) {
items = append(items, item)
}
} }
remoteItems, _ := svc.EmbyRemote.RemoteSearchMedia(ctx, q, limit, visibility)
return remoteItems
} }
if c.DefaultQuery("group_series", "0") != "0" { if c.DefaultQuery("group_series", "0") != "0" {
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility) items = service.GroupMediaSeriesItems(items)
if err != nil { sortSearchMedia(items, sortBy)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) writeMediaSearchResults(c, items, page, size, limit, paginated)
return return
} }
remoteItems := fetchRemote(50000) if c.DefaultQuery("group_versions", "1") != "0" {
all := service.GroupMediaSeriesItems(append(localItems, remoteItems...)) grouped := service.GroupMediaVersions(items)
if sortBy != "" {
if c.Query("page") != "" || c.Query("page_size") != "" { sort.SliceStable(grouped, func(i, j int) bool {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) return mediaSearchSortLess(grouped[i].Media, grouped[j].Media, sortBy)
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
paged := paginateSlice(all, page, size)
c.JSON(http.StatusOK, gin.H{
"items": paged,
"total": len(all),
"page": page,
"page_size": size,
}) })
}
writeMediaSearchResults(c, grouped, page, size, limit, paginated)
return return
} }
sortSearchMedia(items, sortBy)
writeMediaSearchResults(c, items, page, size, limit, paginated)
}
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) func writeMediaSearchResults[T any](c *gin.Context, items []T, page, size, limit int, paginated bool) {
if limit <= 0 {
limit = 50
}
if len(all) > limit {
all = all[:limit]
}
c.JSON(http.StatusOK, gin.H{"items": all})
return
}
if c.Query("page") != "" || c.Query("page_size") != "" {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
if !groupVersions {
localItems, _, err := svc.Media.SearchMediaVisiblePage(ctx, q, 1, 50000, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(size * 2)
all := append(localItems, remoteItems...)
paged := paginateSlice(all, page, size)
c.JSON(http.StatusOK, gin.H{
"items": paged,
"total": len(all),
"page": page,
"page_size": size,
})
return
}
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(size * 2)
all := append(localItems, remoteItems...)
grouped := service.GroupMediaVersions(all)
paged := service.PaginateMediaItems(grouped, page, size)
c.JSON(http.StatusOK, gin.H{
"items": paged,
"total": len(grouped),
"page": page,
"page_size": size,
})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit <= 0 {
limit = 50
}
if !groupVersions {
localItems, err := svc.Media.SearchMediaVisible(ctx, q, limit, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(limit)
all := append(localItems, remoteItems...)
if len(all) > limit {
all = all[:limit]
}
if all == nil {
all = []model.Media{}
}
c.JSON(http.StatusOK, gin.H{"items": all})
return
}
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(limit)
all := append(localItems, remoteItems...)
grouped := service.GroupMediaVersions(all)
items := service.FirstMediaItems(grouped, limit)
if items == nil { if items == nil {
items = []service.MediaItem{} items = []T{}
}
if paginated {
c.JSON(http.StatusOK, gin.H{
"items": paginateSlice(items, page, size),
"total": len(items), "page": page, "page_size": size,
})
return
}
if len(items) > limit {
items = items[:limit]
} }
c.JSON(http.StatusOK, gin.H{"items": items}) c.JSON(http.StatusOK, gin.H{"items": items})
} }
func remoteSearchMatchesFilter(item model.Media, filter service.MediaSearchFilter) bool {
if filter.LibraryID != "" && item.LibraryID != filter.LibraryID && item.DisplayLibraryID != filter.LibraryID {
return false
}
if filter.YearMin > 0 && item.Year < filter.YearMin || filter.YearMax > 0 && item.Year > filter.YearMax {
return false
}
if len(filter.Genres) == 0 {
return true
}
normalize := func(value string) string {
return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(value), " ", ""))
}
for _, genre := range strings.Split(strings.ReplaceAll(item.Genres, ",", ","), ",") {
for _, wanted := range filter.Genres {
if normalize(genre) == normalize(wanted) {
return true
}
}
}
return false
}
func normalizeMediaSearchSort(value string) string {
value = strings.TrimSpace(value)
switch value {
case "title_asc", "year_desc", "rating_desc":
return value
default:
return ""
}
}
func sortSearchMedia(items []model.Media, sortBy string) {
if sortBy == "" {
return
}
sort.SliceStable(items, func(i, j int) bool {
return mediaSearchSortLess(items[i], items[j], sortBy)
})
}
func mediaSearchSortLess(a, b model.Media, sortBy string) bool {
switch sortBy {
case "title_asc":
left, right := strings.ToLower(strings.TrimSpace(a.Title)), strings.ToLower(strings.TrimSpace(b.Title))
if left != right {
return left < right
}
case "year_desc":
if a.Year != b.Year {
return a.Year > b.Year
}
case "rating_desc":
if a.Rating != b.Rating {
return a.Rating > b.Rating
}
}
if !a.CreatedAt.Equal(b.CreatedAt) {
return a.CreatedAt.After(b.CreatedAt)
}
return a.ID < b.ID
}
func boundedSearchYear(value string) int {
year, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || year < 1870 || year > 2200 {
return 0
}
return year
} }
func streamHandler(svc *service.Container) gin.HandlerFunc { func streamHandler(svc *service.Container) gin.HandlerFunc {
+37
View File
@@ -0,0 +1,37 @@
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
func mediaQualityHandler(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"))
if page < 1 {
page = 1
} else if page > 1000000 {
page = 1000000
}
if pageSize < 1 {
pageSize = 50
} else if pageSize > 100 {
pageSize = 100
}
issue := c.DefaultQuery("issue", "unmatched")
if issue != "unmatched" && issue != "poster" && issue != "missing" {
c.JSON(http.StatusBadRequest, gin.H{"error": "未知检查分类"})
return
}
items, total, err := svc.Media.QualityIssues(c.Request.Context(), issue, page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": pageSize})
}
}
@@ -0,0 +1,88 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestSearchMediaSortLimitFiltersAndDeepPagination(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
defer sqlDB.Close()
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.User{}, &model.Setting{}, &model.PlayProfile{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
lib := model.Library{Name: "Review", Path: "/review", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
rows := make([]model.Media, 2001)
for i := range rows {
rows[i] = model.Media{
Base: model.Base{ID: fmt.Sprintf("review-%04d", i)}, LibraryID: lib.ID,
Title: fmt.Sprintf("Review %04d", i), Path: fmt.Sprintf("/review/%04d.mp4", i), Year: 2020, Genres: "Comedy",
}
}
rows[2000].Year = 2025
rows[2000].Genres = "Drama"
if err := db.CreateInBatches(rows, 100).Error; err != nil {
t.Fatal(err)
}
svc := &service.Container{Repo: repos, Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos)}
cases := []struct {
name, query, expectedID string
count, total, page, size int
}{
{name: "sorted output limit", query: "sort=year_desc&limit=1", expectedID: "review-2000", count: 1},
{name: "page past old 2000 cap", query: "sort=title_asc&page=2001&page_size=1", expectedID: "review-2000", count: 1, total: 2001, page: 2001, size: 1},
{name: "genre filtering", query: "genre=Drama&library_id=" + lib.ID, expectedID: "review-2000", count: 1},
{name: "normalized page", query: "page=0&page_size=0", count: 50, total: 2001, page: 1, size: 50},
{name: "bounded page size", query: "page=1&page_size=99999", count: 2000, total: 2001, page: 1, size: 2000},
{name: "huge page does not overflow", query: "page=" + strconv.Itoa(int(^uint(0)>>1)) + "&page_size=2000", count: 0, total: 2001, page: int(^uint(0) >> 1), size: 2000},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(w)
ctx.Set(middleware.CtxUserID, "admin")
ctx.Set(middleware.CtxUserRole, "admin")
ctx.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=Review&group_versions=0&"+tc.query, nil)
searchMediaHandler(svc)(ctx)
var response struct {
Items []model.Media `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"page_size"`
}
if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &response) != nil {
t.Fatalf("search failed: status=%d body=%s", w.Code, w.Body.String())
}
if len(response.Items) != tc.count || response.Total != tc.total || response.Page != tc.page || response.Size != tc.size {
t.Fatalf("response count=%d total=%d page=%d size=%d, want %#v", len(response.Items), response.Total, response.Page, response.Size, tc)
}
if tc.expectedID != "" && response.Items[0].ID != tc.expectedID {
t.Fatalf("first ID=%q want %q", response.Items[0].ID, tc.expectedID)
}
})
}
}
+7
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"testing" "testing"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -28,6 +29,7 @@ func TestSearchMediaSeriesKeepsRemoteLibrariesSeparate(t *testing.T) {
"Items": []map[string]any{{ "Items": []map[string]any{{
"Id": "same-series", "Name": "龙珠改", "Type": "Series", "Id": "same-series", "Name": "龙珠改", "Type": "Series",
"ProviderIds": map[string]string{"Tmdb": "61709"}, "ProviderIds": map[string]string{"Tmdb": "61709"},
"ProductionYear": 2024, "Genres": []string{"Science Fiction"},
}}, }},
}) })
})) }))
@@ -108,4 +110,9 @@ func TestSearchMediaSeriesKeepsRemoteLibrariesSeparate(t *testing.T) {
} }
seen[item.DisplayLibraryID] = true seen[item.DisplayLibraryID] = true
} }
remoteLibraryID := service.EncodeEmbyRemoteID("mount-a", "view-a")
items, total := request("page=1&page_size=8&library_id=" + url.QueryEscape(remoteLibraryID) + "&year_min=2020&genre=Science+Fiction")
if total != 1 || len(items) != 1 || items[0].DisplayLibraryID != remoteLibraryID {
t.Fatalf("remote library/year/genre filters lost results: total=%d items=%#v", total, items)
}
} }
+1 -1
View File
@@ -149,10 +149,10 @@ func readerDeleteSourceHandler(svc *service.Container) gin.HandlerFunc {
} }
func readerDebugSourceHandler(svc *service.Container) gin.HandlerFunc { func readerDebugSourceHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var body struct { var body struct {
Key string `json:"key" binding:"required"` Key string `json:"key" binding:"required"`
} }
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
+3
View File
@@ -13,6 +13,8 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.
admin := api.Group("/admin") admin := api.Group("/admin")
admin.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret), middleware.AdminRequired()) admin.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret), middleware.AdminRequired())
registerAdminUserRoutes(admin, svc) registerAdminUserRoutes(admin, svc)
admin.GET("/tasks", backgroundTasksHandler(svc))
admin.GET("/quality", mediaQualityHandler(svc))
registerAdminPermissionRoutes(admin, svc) registerAdminPermissionRoutes(admin, svc)
registerAdminSystemRoutes(admin, svc) registerAdminSystemRoutes(admin, svc)
registerAdminBackupRoutes(admin, svc) registerAdminBackupRoutes(admin, svc)
@@ -143,6 +145,7 @@ func registerAdminSystemRoutes(admin *gin.RouterGroup, svc *service.Container) {
func registerAdminBackupRoutes(admin *gin.RouterGroup, svc *service.Container) { func registerAdminBackupRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.GET("/backups", listBackupsHandler(svc)) admin.GET("/backups", listBackupsHandler(svc))
admin.POST("/backups", createBackupHandler(svc)) admin.POST("/backups", createBackupHandler(svc))
admin.GET("/backups/download", downloadBackupHandler(svc))
admin.DELETE("/backups", deleteBackupHandler(svc)) admin.DELETE("/backups", deleteBackupHandler(svc))
admin.POST("/backups/restore", restoreBackupHandler(svc)) admin.POST("/backups/restore", restoreBackupHandler(svc))
} }
+23
View File
@@ -0,0 +1,23 @@
package model
import "time"
// BackgroundTaskRecord persists the operator-facing lifecycle of scan,
// organize, and other long-running application tasks.
type BackgroundTaskRecord struct {
ID string `gorm:"primaryKey;size:36" json:"id"`
Kind string `gorm:"index;size:32;not null" json:"kind"`
Name string `gorm:"size:255;not null" json:"name"`
Status string `gorm:"index;size:24;not null" json:"status"`
Stage string `gorm:"size:64" json:"stage"`
SourcePath string `gorm:"size:2048" json:"source_path"`
DestPath string `gorm:"size:2048" json:"dest_path"`
Message string `gorm:"type:text" json:"message"`
Error string `gorm:"type:text" json:"error"`
Details string `gorm:"type:text" json:"details"`
Metrics string `gorm:"type:text" json:"metrics"`
Items string `gorm:"type:text" json:"items"`
StartedAt time.Time `gorm:"index" json:"started_at"`
UpdatedAt time.Time `json:"updated_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
}
+5
View File
@@ -1,5 +1,7 @@
package model package model
import "time"
// Library 表示一个逻辑媒体库。Path 保留为兼容字段,指向第一条 LibraryRoot。 // Library 表示一个逻辑媒体库。Path 保留为兼容字段,指向第一条 LibraryRoot。
type Library struct { type Library struct {
Base Base
@@ -26,6 +28,9 @@ type LibraryRoot struct {
// Media 是单个可播放项。剧集链接到 SeriesID;电影 SeriesID == ""。 // Media 是单个可播放项。剧集链接到 SeriesID;电影 SeriesID == ""。
type Media struct { type Media struct {
Base Base
AvailabilityState string `gorm:"index;size:16;not null;default:available" json:"availability_state"`
MissingSince *time.Time `json:"missing_since,omitempty"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
LibraryID string `gorm:"index;size:36;index:idx_media_library_release,priority:1" json:"library_id"` LibraryID string `gorm:"index;size:36;index:idx_media_library_release,priority:1" json:"library_id"`
LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"` LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"`
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"` SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
+1
View File
@@ -61,6 +61,7 @@ func AllModels() []interface{} {
&StrmUploadTask{}, &StrmUploadTask{},
&StrmDirCache{}, &StrmDirCache{},
&ScrapeTask{}, &ScrapeTask{},
&BackgroundTaskRecord{},
&EmbyMount{}, &EmbyMount{},
&ReaderBookSource{}, &ReaderBookSource{},
&ReaderSourceState{}, &ReaderSourceState{},
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -163,6 +164,12 @@ func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *mode
existing.Path = m.Path existing.Path = m.Path
existing.DeletedAt = gorm.DeletedAt{} existing.DeletedAt = gorm.DeletedAt{}
} }
if _, ok := updates["availability_state"]; ok {
existing.AvailabilityState = "available"
existing.MissingSince = nil
now := updates["last_seen_at"].(time.Time)
existing.LastSeenAt = &now
}
*m = existing *m = existing
return existing.ID, nil return existing.ID, nil
} }
@@ -216,6 +223,10 @@ func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.
if m.ScrapeStatus == "" { if m.ScrapeStatus == "" {
m.ScrapeStatus = "pending" m.ScrapeStatus = "pending"
} }
now := time.Now()
m.AvailabilityState = "available"
m.MissingSince = nil
m.LastSeenAt = &now
// PostgreSQL 唯一键异常会中止整个事务,之后重查也会失败。 // PostgreSQL 唯一键异常会中止整个事务,之后重查也会失败。
// DO NOTHING 保持事务可用,并兼容 path 的表达式唯一索引。 // DO NOTHING 保持事务可用,并兼容 path 的表达式唯一索引。
result := db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(m) result := db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(m)
@@ -265,6 +276,11 @@ func (r *MediaRepository) findMediaByAlias(ctx context.Context, db *gorm.DB, cur
func mediaUpsertUpdates(existing, incoming model.Media, reclaimLibrary bool) map[string]any { func mediaUpsertUpdates(existing, incoming model.Media, reclaimLibrary bool) map[string]any {
updates := map[string]any{} updates := map[string]any{}
if existing.AvailabilityState != "available" || existing.MissingSince != nil {
updates["availability_state"] = "available"
updates["missing_since"] = nil
updates["last_seen_at"] = time.Now()
}
addMediaFileScanUpdates(updates, existing, incoming) addMediaFileScanUpdates(updates, existing, incoming)
addMediaTitleUpdates(updates, existing, incoming) addMediaTitleUpdates(updates, existing, incoming)
addMediaExternalIDUpdates(updates, existing, incoming) addMediaExternalIDUpdates(updates, existing, incoming)
+81 -37
View File
@@ -5,7 +5,7 @@
// //
// 两种方言的备份形态不同: // 两种方言的备份形态不同:
// - SQLite:用 "VACUUM INTO"(3.27.0 起可用)产出一个一致的库文件, // - SQLite:用 "VACUUM INTO"(3.27.0 起可用)产出一个一致的库文件,
// 扩展名 .db;恢复即「替换库文件 + 重启」。 // 扩展名 .db;恢复先暂存,在下次启动打开数据库前替换文件。
// - PostgreSQL:没有单文件语义,用逻辑备份(见 backup_postgres.go), // - PostgreSQL:没有单文件语义,用逻辑备份(见 backup_postgres.go),
// 扩展名 .json.gz;恢复是在单个事务内重建全部表。 // 扩展名 .json.gz;恢复是在单个事务内重建全部表。
package service package service
@@ -24,6 +24,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config" "github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/database"
) )
// BackupService manages database backups. // BackupService manages database backups.
@@ -44,6 +45,7 @@ type BackupInfo struct {
Path string `json:"path"` Path string `json:"path"`
Size int64 `json:"size"` Size int64 `json:"size"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
Automatic bool `json:"automatic"`
} }
func (b *BackupService) backupDir() string { func (b *BackupService) backupDir() string {
@@ -69,6 +71,23 @@ func (b *BackupService) backupFilePath(filename string) (string, error) {
return path, nil return path, nil
} }
// ResolvePath returns the absolute path of a listed backup after applying the
// same filename and directory checks used by restore and delete operations.
func (b *BackupService) ResolvePath(filename string) (string, error) {
path, err := b.backupFilePath(filename)
if err != nil {
return "", err
}
info, err := os.Lstat(path)
if err != nil {
return "", err
}
if !info.Mode().IsRegular() {
return "", errors.New("backup is not a regular file")
}
return path, nil
}
// isPostgres reports whether the service is backed by PostgreSQL. // isPostgres reports whether the service is backed by PostgreSQL.
// //
// PostgreSQL 不支持 SQLite 的 "VACUUM INTO",按文件替换恢复也会写错目标, // PostgreSQL 不支持 SQLite 的 "VACUUM INTO",按文件替换恢复也会写错目标,
@@ -80,11 +99,23 @@ func (b *BackupService) isPostgres() bool {
// Create produces a new hot backup. SQLite uses "VACUUM INTO"; PostgreSQL uses // Create produces a new hot backup. SQLite uses "VACUUM INTO"; PostgreSQL uses
// a logical dump. // a logical dump.
func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) { func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) {
return b.create(ctx, false)
}
func (b *BackupService) CreateAutomatic(ctx context.Context) (*BackupInfo, error) {
return b.create(ctx, true)
}
func (b *BackupService) create(ctx context.Context, automatic bool) (*BackupInfo, error) {
dir := b.backupDir() dir := b.backupDir()
if err := os.MkdirAll(dir, 0o750); err != nil { if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, err return nil, err
} }
ts := time.Now().Format("20060102_150405") now := time.Now()
ts := fmt.Sprintf("%s_%09d", now.Format("20060102_150405"), now.Nanosecond())
if automatic {
ts = "auto_" + ts
}
var ( var (
dst string dst string
@@ -108,15 +139,24 @@ func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) {
Path: dst, Path: dst,
Size: stat.Size(), Size: stat.Size(),
CreatedAt: stat.ModTime(), CreatedAt: stat.ModTime(),
Automatic: automatic,
}, nil }, nil
} }
func (b *BackupService) createSQLiteBackup(ctx context.Context, stamp string) (string, error) { func (b *BackupService) createSQLiteBackup(ctx context.Context, stamp string) (string, error) {
dst := filepath.Join(b.backupDir(), fmt.Sprintf("mebox_%s.db", stamp)) dst := filepath.Join(b.backupDir(), fmt.Sprintf("mebox_%s.db", stamp))
// VACUUM INTO creates a clean, non-WAL copy atomically. tmp := dst + ".tmp"
if err := b.db.WithContext(ctx).Exec("VACUUM INTO ?", dst).Error; err != nil { defer os.Remove(tmp)
// Publish only completed backups, with credentials protected by file mode.
if err := b.db.WithContext(ctx).Exec("VACUUM INTO ?", tmp).Error; err != nil {
return "", fmt.Errorf("VACUUM INTO failed: %w", err) return "", fmt.Errorf("VACUUM INTO failed: %w", err)
} }
if err := os.Chmod(tmp, 0o600); err != nil {
return "", err
}
if err := os.Rename(tmp, dst); err != nil {
return "", err
}
return dst, nil return dst, nil
} }
@@ -132,7 +172,7 @@ func (b *BackupService) List() ([]BackupInfo, error) {
} }
out := make([]BackupInfo, 0, len(entries)) out := make([]BackupInfo, 0, len(entries))
for _, e := range entries { for _, e := range entries {
if e.IsDir() || !isBackupFilename(e.Name()) { if !e.Type().IsRegular() || !isBackupFilename(e.Name()) {
continue continue
} }
info, err := e.Info() info, err := e.Info()
@@ -144,6 +184,7 @@ func (b *BackupService) List() ([]BackupInfo, error) {
Path: filepath.Join(dir, e.Name()), Path: filepath.Join(dir, e.Name()),
Size: info.Size(), Size: info.Size(),
CreatedAt: info.ModTime(), CreatedAt: info.ModTime(),
Automatic: strings.HasPrefix(e.Name(), "mebox_auto_"),
}) })
} }
sort.Slice(out, func(i, j int) bool { sort.Slice(out, func(i, j int) bool {
@@ -152,6 +193,36 @@ func (b *BackupService) List() ([]BackupInfo, error) {
return out, nil return out, nil
} }
// Prune keeps maxCount automatic backups for the current database dialect.
// Manual backups and backups from another dialect are never pruned.
// A value below one disables pruning so an invalid setting cannot erase all
// recoverable copies.
func (b *BackupService) Prune(maxCount int) error {
if maxCount < 1 {
return nil
}
items, err := b.List()
if err != nil {
return err
}
eligible := items[:0]
for _, item := range items {
if item.Automatic && strings.HasSuffix(item.Filename, postgresBackupSuffix) == b.isPostgres() {
eligible = append(eligible, item)
}
}
items = eligible
if maxCount >= len(items) {
return nil
}
for _, item := range items[maxCount:] {
if err := b.Delete(item.Filename); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
}
return nil
}
// Delete removes a single backup file. // Delete removes a single backup file.
func (b *BackupService) Delete(filename string) error { func (b *BackupService) Delete(filename string) error {
path, err := b.backupFilePath(filename) path, err := b.backupFilePath(filename)
@@ -161,12 +232,10 @@ func (b *BackupService) Delete(filename string) error {
return os.Remove(path) return os.Remove(path)
} }
// Restore replaces the live database with the given backup. // Restore stages SQLite backups for the next startup. PostgreSQL restores
// WARNING: this is destructive. SQLite restores by swapping the database file // rebuild every table inside a single transaction.
// (the caller should shut down the server afterwards); PostgreSQL restores by
// rebuilding every table inside a single transaction.
func (b *BackupService) Restore(ctx context.Context, filename string) error { func (b *BackupService) Restore(ctx context.Context, filename string) error {
src, err := b.backupFilePath(filename) src, err := b.ResolvePath(filename)
if err != nil { if err != nil {
return err return err
} }
@@ -182,35 +251,10 @@ func (b *BackupService) Restore(ctx context.Context, filename string) error {
if !strings.HasSuffix(filename, ".db") { if !strings.HasSuffix(filename, ".db") {
return errors.New("该备份是 PostgreSQL 逻辑备份,当前运行在 SQLite 上,无法恢复") return errors.New("该备份是 PostgreSQL 逻辑备份,当前运行在 SQLite 上,无法恢复")
} }
return b.restoreSQLiteBackup(filename, src) if err := database.StageSQLiteRestore(ctx, b.cfg, src); err != nil {
}
func (b *BackupService) restoreSQLiteBackup(filename, src string) error {
dbPath, err := filepath.Abs(b.cfg.Database.DBPath)
if err != nil || dbPath == "" {
return errors.New("invalid database path")
}
// Strategy: rename live → live.old, copy backup → live, delete old.
old := dbPath + ".before_restore"
if err := os.Rename(dbPath, old); err != nil && !errors.Is(err, os.ErrNotExist) {
return err return err
} }
data, err := os.ReadFile(src) // #nosec G304 -- src is constrained by backupFilePath to the configured backups directory. b.log.Warn("SQLite backup staged; restart to apply", zap.String("backup", filename))
if err != nil {
// Revert
_ = os.Rename(old, dbPath)
return err
}
if err := os.WriteFile(dbPath, data, 0o600); err != nil { // #nosec G703 -- database path is trusted process config and normalized before restore.
_ = os.Rename(old, dbPath)
return err
}
_ = os.Remove(old)
// Also remove WAL/SHM so SQLite opens the fresh file cleanly.
_ = os.Remove(dbPath + "-wal")
_ = os.Remove(dbPath + "-shm")
b.log.Warn("database restored from backup — restart the server",
zap.String("backup", filename))
return nil return nil
} }
+30 -9
View File
@@ -3,6 +3,7 @@ package service
import ( import (
"compress/gzip" "compress/gzip"
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -87,7 +88,8 @@ func postgresBackupModelSchemas(db *gorm.DB) (map[string]*schema.Schema, map[str
// createPostgresBackup 把全库导出成一个 gzip 压缩的 JSON Lines 文件。 // createPostgresBackup 把全库导出成一个 gzip 压缩的 JSON Lines 文件。
func (b *BackupService) createPostgresBackup(ctx context.Context, stamp string) (string, error) { func (b *BackupService) createPostgresBackup(ctx context.Context, stamp string) (string, error) {
dst := filepath.Join(b.backupDir(), "mebox_"+stamp+postgresBackupSuffix) dst := filepath.Join(b.backupDir(), "mebox_"+stamp+postgresBackupSuffix)
file, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) tmp := dst + ".tmp"
file, err := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -96,7 +98,7 @@ func (b *BackupService) createPostgresBackup(ctx context.Context, stamp string)
_ = file.Close() _ = file.Close()
if !complete { if !complete {
// 半截备份文件比没有备份更危险:留着会让人以为有可用的还原点。 // 半截备份文件比没有备份更危险:留着会让人以为有可用的还原点。
_ = os.Remove(dst) _ = os.Remove(tmp)
} }
}() }()
@@ -109,24 +111,43 @@ func (b *BackupService) createPostgresBackup(ctx context.Context, stamp string)
}); err != nil { }); err != nil {
return "", fmt.Errorf("写入备份头失败: %w", err) return "", fmt.Errorf("写入备份头失败: %w", err)
} }
for _, m := range model.AllModels() { var options []*sql.TxOptions
table, sch, err := parseModelSchema(b.db, m) if b.isPostgres() {
if err != nil { options = append(options, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
return "", err
} }
if !b.db.Migrator().HasTable(table) { // Every table must come from the same snapshot, including while background
// workers are inserting related rows during an automatic backup.
if err := b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
snapshot := *b
snapshot.db = tx
for _, m := range model.AllModels() {
table, sch, err := parseModelSchema(tx, m)
if err != nil {
return err
}
if !tx.Migrator().HasTable(table) {
continue continue
} }
if err := b.dumpPostgresTable(ctx, m, sch, table, enc); err != nil { if err := snapshot.dumpPostgresTable(ctx, m, sch, table, enc); err != nil {
return "", err return err
} }
} }
return nil
}, options...); err != nil {
return "", err
}
if err := zw.Close(); err != nil { if err := zw.Close(); err != nil {
return "", fmt.Errorf("压缩备份失败: %w", err) return "", fmt.Errorf("压缩备份失败: %w", err)
} }
if err := file.Sync(); err != nil { if err := file.Sync(); err != nil {
return "", fmt.Errorf("写入备份失败: %w", err) return "", fmt.Errorf("写入备份失败: %w", err)
} }
if err := file.Close(); err != nil {
return "", err
}
if err := os.Rename(tmp, dst); err != nil {
return "", err
}
complete = true complete = true
return dst, nil return dst, nil
} }
+65
View File
@@ -0,0 +1,65 @@
package service
import (
"os"
"path/filepath"
"runtime"
"testing"
"time"
)
func TestBackupRetentionPreservesManualAndOtherDialectBackups(t *testing.T) {
svc, _ := newBackupTestService(t)
names := []string{"mebox_auto_new.db", "mebox_auto_old.db", "mebox_manual.db", "mebox_auto_postgres.json.gz"}
for i, name := range names {
path := filepath.Join(svc.backupDir(), name)
if err := os.WriteFile(path, []byte("fixture"), 0o600); err != nil {
t.Fatal(err)
}
when := time.Now().Add(-time.Duration(i) * time.Hour)
if err := os.Chtimes(path, when, when); err != nil {
t.Fatal(err)
}
}
if err := svc.Prune(1); err != nil {
t.Fatal(err)
}
for _, name := range names {
_, err := os.Stat(filepath.Join(svc.backupDir(), name))
if name == "mebox_auto_old.db" {
if !os.IsNotExist(err) {
t.Fatalf("old automatic backup was retained: %v", err)
}
} else if err != nil {
t.Fatalf("backup %s should have been preserved: %v", name, err)
}
}
}
func TestBackupCreateUsesUniquePrivateCompletedFiles(t *testing.T) {
svc, _ := newBackupTestService(t)
manual, err := svc.Create(t.Context())
if err != nil {
t.Fatal(err)
}
automatic, err := svc.CreateAutomatic(t.Context())
if err != nil {
t.Fatal(err)
}
if manual.Filename == automatic.Filename || manual.Automatic || !automatic.Automatic {
t.Fatalf("invalid backup identities: manual=%#v automatic=%#v", manual, automatic)
}
items, err := svc.List()
if err != nil || len(items) != 2 {
t.Fatalf("completed backup list: items=%#v err=%v", items, err)
}
for _, item := range items {
info, err := os.Stat(item.Path)
if err != nil {
t.Fatal(err)
}
if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
t.Fatalf("backup permissions are not private: %s err=%v", item.Path, err)
}
}
}
+66
View File
@@ -2,17 +2,71 @@ package service
import ( import (
"context" "context"
"fmt"
"github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository" "github.com/truewhile/MeBox/internal/repository"
) )
type MediaQualityItem struct {
Issue string `json:"issue"`
Media model.Media `json:"media"`
}
func (s *MediaService) QualityIssues(ctx context.Context, issue string, page, pageSize int) ([]MediaQualityItem, int64, error) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 50
}
if pageSize > 100 {
pageSize = 100
}
q := s.repo.DB.WithContext(ctx).Model(&model.Media{})
switch issue {
case "missing":
q = q.Where("availability_state = ?", "missing")
case "unmatched":
q = q.Where("scrape_status IN ? OR scrape_status = ''", []string{"pending", "no_match"})
case "poster":
q = q.Where("poster_url IS NULL OR TRIM(poster_url) = ''")
default:
return nil, 0, fmt.Errorf("unknown quality issue: %s", issue)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []model.Media
if err := q.Order("updated_at DESC, id ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&rows).Error; err != nil {
return nil, 0, err
}
s.attachLibraryMetadata(ctx, rows)
items := make([]MediaQualityItem, 0, len(rows))
for _, media := range rows {
items = append(items, MediaQualityItem{Issue: issue, Media: media})
}
return items, total, nil
}
type MediaSearchFilter struct {
LibraryID string
YearMin int
YearMax int
Genres []string
}
// SearchMedia performs a simple LIKE search across titles. // SearchMedia performs a simple LIKE search across titles.
func (s *MediaService) SearchMedia(ctx context.Context, query string, limit int) ([]model.Media, error) { func (s *MediaService) SearchMedia(ctx context.Context, query string, limit int) ([]model.Media, error) {
return s.SearchMediaVisible(ctx, query, limit, MediaVisibility{IncludeNSFW: true}) return s.SearchMediaVisible(ctx, query, limit, MediaVisibility{IncludeNSFW: true})
} }
func (s *MediaService) SearchMediaVisible(ctx context.Context, query string, limit int, visibility MediaVisibility) ([]model.Media, error) { func (s *MediaService) SearchMediaVisible(ctx context.Context, query string, limit int, visibility MediaVisibility) ([]model.Media, error) {
return s.SearchMediaVisibleFiltered(ctx, query, limit, visibility, MediaSearchFilter{})
}
func (s *MediaService) SearchMediaVisibleFiltered(ctx context.Context, query string, limit int, visibility MediaVisibility, filter MediaSearchFilter) ([]model.Media, error) {
if limit <= 0 { if limit <= 0 {
limit = 50 limit = 50
} else if limit > maxMediaSearchLimit { } else if limit > maxMediaSearchLimit {
@@ -23,6 +77,10 @@ func (s *MediaService) SearchMediaVisible(ctx context.Context, query string, lim
IncludeNSFW: visibility.IncludeNSFW, IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs, AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs, HiddenLibraryIDs: visibility.HiddenLibraryIDs,
LibraryID: filter.LibraryID,
YearMin: filter.YearMin,
YearMax: filter.YearMax,
Genres: filter.Genres,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -45,6 +103,10 @@ func (s *MediaService) SearchMediaVisibleGrouped(ctx context.Context, query stri
} }
func (s *MediaService) SearchMediaVisiblePage(ctx context.Context, query string, page, pageSize int, visibility MediaVisibility) ([]model.Media, int64, error) { func (s *MediaService) SearchMediaVisiblePage(ctx context.Context, query string, page, pageSize int, visibility MediaVisibility) ([]model.Media, int64, error) {
return s.SearchMediaVisiblePageFiltered(ctx, query, page, pageSize, visibility, MediaSearchFilter{})
}
func (s *MediaService) SearchMediaVisiblePageFiltered(ctx context.Context, query string, page, pageSize int, visibility MediaVisibility, filter MediaSearchFilter) ([]model.Media, int64, error) {
if pageSize <= 0 { if pageSize <= 0 {
pageSize = 50 pageSize = 50
} }
@@ -59,6 +121,10 @@ func (s *MediaService) SearchMediaVisiblePage(ctx context.Context, query string,
IncludeNSFW: visibility.IncludeNSFW, IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs, AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs, HiddenLibraryIDs: visibility.HiddenLibraryIDs,
LibraryID: filter.LibraryID,
YearMin: filter.YearMin,
YearMax: filter.YearMax,
Genres: filter.Genres,
}) })
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
+3
View File
@@ -14,6 +14,7 @@ import (
"errors" "errors"
"strings" "strings"
"sync" "sync"
"time"
"go.uber.org/zap" "go.uber.org/zap"
@@ -144,6 +145,8 @@ type localMediaProbeTask struct {
} }
type existingLocalMedia struct { type existingLocalMedia struct {
AvailabilityState string
MissingSince *time.Time
LibraryRootID string LibraryRootID string
RelativePath string RelativePath string
Title string Title string
+3 -1
View File
@@ -33,7 +33,7 @@ func (s *ScannerService) existingLocalMediaSnapshotForRoot(ctx context.Context,
query = query.Where("(library_root_id = ? OR library_root_id = '' OR library_root_id IS NULL)", rootID) query = query.Where("(library_root_id = ? OR library_root_id = '' OR library_root_id IS NULL)", rootID)
} }
rows, err := query. rows, err := query.
Select("path", "library_root_id", "relative_path", "title", "original_name", "episode_title", "size_bytes", "duration_sec", "width", "height", "video_codec", "audio_codec", "container", "strm_url", "file_id", "poster_url", "backdrop_url", "overview", "year", "release_date", "rating", "tm_db_id", "bangumi_id", "douban_id", "thetvdb_id", "season_num", "episode_num", "episode_fraction", "genres", "countries", "languages", "nsfw", "scrape_status"). Select("path", "library_root_id", "relative_path", "title", "original_name", "episode_title", "size_bytes", "duration_sec", "width", "height", "video_codec", "audio_codec", "container", "strm_url", "file_id", "poster_url", "backdrop_url", "overview", "year", "release_date", "rating", "tm_db_id", "bangumi_id", "douban_id", "thetvdb_id", "season_num", "episode_num", "episode_fraction", "genres", "countries", "languages", "nsfw", "scrape_status", "availability_state", "missing_since").
Rows() Rows()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -80,6 +80,8 @@ func (s *ScannerService) existingLocalMediaSnapshotForRoot(ctx context.Context,
&row.Languages, &row.Languages,
&row.NSFW, &row.NSFW,
&row.ScrapeStatus, &row.ScrapeStatus,
&row.AvailabilityState,
&row.MissingSince,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
+10 -3
View File
@@ -548,7 +548,7 @@ func TestScanLibraryMapsPersistedHostLibraryPath(t *testing.T) {
} }
} }
func TestRemovePathDeletesVanishedMedia(t *testing.T) { func TestRemovePathRetainsVanishedMedia(t *testing.T) {
sc, repos := newScannerTestEnv(t) sc, repos := newScannerTestEnv(t)
cache := NewRuntimeCacheService(&config.Config{}, zap.NewNop()) cache := NewRuntimeCacheService(&config.Config{}, zap.NewNop())
sc.SetRuntimeCache(cache) sc.SetRuntimeCache(cache)
@@ -589,8 +589,15 @@ func TestRemovePathDeletesVanishedMedia(t *testing.T) {
if removed != 1 { if removed != 1 {
t.Fatalf("removed = %d, want 1", removed) t.Fatalf("removed = %d, want 1", removed)
} }
if countMedia(t, repos) != 0 { if countMedia(t, repos) != 1 {
t.Fatal("expected 0 media after removal") t.Fatal("missing media metadata must be retained")
}
var missing model.Media
if err := repos.DB.Where("path = ?", file).First(&missing).Error; err != nil {
t.Fatal(err)
}
if missing.AvailabilityState != "missing" || missing.MissingSince == nil {
t.Fatalf("missing state not recorded: %#v", missing)
} }
if cache.GetJSON(t.Context(), "media:list:test", &cached) { if cache.GetJSON(t.Context(), "media:list:test", &cached) {
t.Fatal("vanished media removal should invalidate media cache") t.Fatal("vanished media removal should invalidate media cache")
+10 -2
View File
@@ -67,8 +67,16 @@ func TestScanLibraryScansMultipleRootsAndPrunesPerRoot(t *testing.T) {
if err := db.Find(&rows).Error; err != nil { if err := db.Find(&rows).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(rows) != 1 || rows[0].Path != fileB { if len(rows) != 2 {
t.Fatalf("remaining rows = %#v, want offline root media preserved", rows) t.Fatalf("remaining rows = %#v, want both media records preserved", rows)
}
for _, row := range rows {
if row.Path == fileA && row.AvailabilityState != "missing" {
t.Fatalf("vanished file was not marked missing: %#v", row)
}
if row.Path == fileB && row.AvailabilityState != "available" {
t.Fatalf("offline root status was changed: %#v", row)
}
} }
} }
+1 -1
View File
@@ -122,7 +122,7 @@ func (s *ScannerService) localMediaScanState(in localMediaScanStateInput) (bool,
} }
existing, exists := in.existingMedia[in.cleanPath] existing, exists := in.existingMedia[in.cleanPath]
isNewMedia := !exists isNewMedia := !exists
if exists && in.ext != ".strm" && existing.SizeBytes == in.size && !localMetadataNeedsRefresh(existing, in.localMeta) && !localDerivedMetadataNeedsRefresh(existing, in.incoming) { if exists && existing.AvailabilityState != "missing" && existing.MissingSince == nil && in.ext != ".strm" && existing.SizeBytes == in.size && !localMetadataNeedsRefresh(existing, in.localMeta) && !localDerivedMetadataNeedsRefresh(existing, in.incoming) {
// Unchanged rows are skipped, but a row that never received ffprobe data // Unchanged rows are skipped, but a row that never received ffprobe data
// still needs a backfill probe so installing ffmpeg later repairs the library. // still needs a backfill probe so installing ffmpeg later repairs the library.
s.queueProbeBackfillIfNeeded(in.path, in.ext, existing) s.queueProbeBackfillIfNeeded(in.path, in.ext, existing)
@@ -372,7 +372,14 @@ func TestScanLibraryPrunesMissingMedia(t *testing.T) {
if err := db.Model(&model.Media{}).Where("library_id = ?", lib.ID).Count(&count).Error; err != nil { if err := db.Model(&model.Media{}).Where("library_id = ?", lib.ID).Count(&count).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
if count != 1 { if count != 2 {
t.Fatalf("media count = %d, want 1", count) t.Fatalf("media count = %d, want current and missing records preserved", count)
}
var missing model.Media
if err := db.First(&missing, "id = ?", stale.ID).Error; err != nil {
t.Fatal(err)
}
if missing.AvailabilityState != "missing" {
t.Fatalf("stale media state = %q, want missing", missing.AvailabilityState)
} }
} }
+55
View File
@@ -0,0 +1,55 @@
package service
import (
"os"
"path/filepath"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
func TestScanRestoresUnchangedMissingFileAndMetadata(t *testing.T) {
scanner, repos := newScannerTestEnv(t)
root := t.TempDir()
library := model.Library{Name: "Movies", Path: root, Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &library); err != nil {
t.Fatal(err)
}
path := filepath.Join(root, "Example (2024).mkv")
writeTestFile(t, path, "unchanged content")
if _, err := scanner.ScanLibrary(t.Context(), library.ID); err != nil {
t.Fatal(err)
}
var original model.Media
if err := repos.DB.Where("path = ?", path).First(&original).Error; err != nil {
t.Fatal(err)
}
if err := repos.DB.Model(&original).Updates(map[string]any{
"title": "Manually matched title", "scrape_status": "matched", "tm_db_id": 42,
}).Error; err != nil {
t.Fatal(err)
}
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if changed, err := scanner.RemovePath(t.Context(), path); err != nil || changed != 1 {
t.Fatalf("mark missing: changed=%d err=%v", changed, err)
}
if changed, err := scanner.RemovePath(t.Context(), path); err != nil || changed != 0 {
t.Fatalf("repeated missing event: changed=%d err=%v", changed, err)
}
writeTestFile(t, path, "unchanged content")
if _, err := scanner.ScanLibrary(t.Context(), library.ID); err != nil {
t.Fatal(err)
}
var restored model.Media
if err := repos.DB.Where("path = ?", path).First(&restored).Error; err != nil {
t.Fatal(err)
}
if restored.ID != original.ID || restored.TMDbID != 42 || restored.Title != "Manually matched title" {
t.Fatalf("restored file lost identity or metadata: %#v", restored)
}
if restored.AvailabilityState != "available" || restored.MissingSince != nil || restored.LastSeenAt == nil {
t.Fatalf("unchanged restored file stayed missing: %#v", restored)
}
}
+44 -15
View File
@@ -2,6 +2,7 @@ package service
import ( import (
"context" "context"
"errors"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -12,11 +13,15 @@ import (
"github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/model"
) )
// RemovePath 移除磁盘上已不存在的媒体记录。普通的删除仍做物理删除; // RemovePath 将确认不存在的媒体标记为失联;只有可确认的 STRM 扩展名改名
// 可确认是 STRM 扩展名改名的路径则先保留软删除墓碑,供后续 ingest 继承元数据。 // 才保留软删除墓碑,供后续 ingest 继承元数据。
func (s *ScannerService) RemovePath(ctx context.Context, path string) (int64, error) { func (s *ScannerService) RemovePath(ctx context.Context, path string) (int64, error) {
if _, err := os.Stat(path); err == nil { if _, err := os.Stat(path); err == nil {
return 0, nil // still exists; nothing to remove return 0, nil // still exists; nothing to remove
} else if !errors.Is(err, os.ErrNotExist) {
// Permission, I/O, and mount errors are not evidence that a file was
// removed. Preserve records until the path can be inspected reliably.
return 0, nil
} }
// 目录整体消失(删除/改名离开):连同其子树下的媒体行一并移除。 // 目录整体消失(删除/改名离开):连同其子树下的媒体行一并移除。
// 此前只删 path 精确匹配的行——目录本身通常没有 media 行,导致目录 // 此前只删 path 精确匹配的行——目录本身通常没有 media 行,导致目录
@@ -33,7 +38,7 @@ func (s *ScannerService) RemovePath(ctx context.Context, path string) (int64, er
return 0, err return 0, err
} }
softIDs := make([]string, 0, len(rows)) softIDs := make([]string, 0, len(rows))
hardIDs := make([]string, 0, len(rows)) missingIDs := make([]string, 0, len(rows))
for _, row := range rows { for _, row := range rows {
// LIKE 里的 % _ 是通配符(候选集只会偏大),用 Go 前缀精确过滤, // LIKE 里的 % _ 是通配符(候选集只会偏大),用 Go 前缀精确过滤,
// 避免对含 % / _ 的路径误删。 // 避免对含 % / _ 的路径误删。
@@ -43,12 +48,12 @@ func (s *ScannerService) RemovePath(ctx context.Context, path string) (int64, er
// A vanished STRM with a live sibling (foo.strm <-> foo.mkv.strm) // A vanished STRM with a live sibling (foo.strm <-> foo.mkv.strm)
// is a rename. Keep a soft-deleted tombstone so the later ingest can // is a rename. Keep a soft-deleted tombstone so the later ingest can
// adopt its ID and scraped metadata even if fsnotify delivers the // adopt its ID and scraped metadata even if fsnotify delivers the
// remove event before the create event. A true deletion stays hard. // remove event before the create event.
if row.Path == path && liveSTRMPathAlias(path) { if row.Path == path && liveSTRMPathAlias(path) {
softIDs = append(softIDs, row.ID) softIDs = append(softIDs, row.ID)
continue continue
} }
hardIDs = append(hardIDs, row.ID) missingIDs = append(missingIDs, row.ID)
} }
var removed int64 var removed int64
if len(softIDs) > 0 { if len(softIDs) > 0 {
@@ -60,14 +65,12 @@ func (s *ScannerService) RemovePath(ctx context.Context, path string) (int64, er
} }
removed += res.RowsAffected removed += res.RowsAffected
} }
if len(hardIDs) > 0 { if len(missingIDs) > 0 {
res := s.repo.DB.WithContext(ctx).Unscoped(). count, err := s.markMediaMissingByIDs(ctx, missingIDs)
Where("id IN ?", hardIDs). if err != nil {
Delete(&model.Media{}) return removed, err
if res.Error != nil {
return removed, res.Error
} }
removed += res.RowsAffected removed += count
} }
if removed > 0 { if removed > 0 {
s.invalidateMediaCache(ctx) s.invalidateMediaCache(ctx)
@@ -82,7 +85,7 @@ const strmRenameTombstoneRetention = 7 * 24 * time.Hour
// purgeExpiredSTRMTombstones keeps the soft-delete grace period bounded. // purgeExpiredSTRMTombstones keeps the soft-delete grace period bounded.
// It is deliberately restricted to deleted media rows under a STRM alias // It is deliberately restricted to deleted media rows under a STRM alias
// workflow; normal deletions are still hard-deleted immediately. // workflow; ordinary missing files retain their metadata for recovery.
func (s *ScannerService) purgeExpiredSTRMTombstones(ctx context.Context) { func (s *ScannerService) purgeExpiredSTRMTombstones(ctx context.Context) {
cutoff := time.Now().Add(-strmRenameTombstoneRetention) cutoff := time.Now().Add(-strmRenameTombstoneRetention)
if err := s.repo.DB.WithContext(ctx).Unscoped(). if err := s.repo.DB.WithContext(ctx).Unscoped().
@@ -121,7 +124,7 @@ func (s *ScannerService) pruneMissingMedia(ctx context.Context, libraryID string
} }
stale = append(stale, row.ID) stale = append(stale, row.ID)
} }
return s.deleteMediaByIDs(ctx, stale, true) return s.markMediaMissingByIDs(ctx, stale)
} }
func (s *ScannerService) pruneMissingMediaForRoot(ctx context.Context, libraryID, rootID, rootPath string, seen map[string]struct{}) (int64, error) { func (s *ScannerService) pruneMissingMediaForRoot(ctx context.Context, libraryID, rootID, rootPath string, seen map[string]struct{}) (int64, error) {
@@ -158,7 +161,33 @@ func (s *ScannerService) pruneMissingMediaForRoot(ctx context.Context, libraryID
} }
stale = append(stale, row.ID) stale = append(stale, row.ID)
} }
return s.deleteMediaByIDs(ctx, stale, true) return s.markMediaMissingByIDs(ctx, stale)
}
func (s *ScannerService) markMediaMissingByIDs(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
const batch = 500
var changed int64
now := time.Now()
for i := 0; i < len(ids); i += batch {
end := i + batch
if end > len(ids) {
end = len(ids)
}
result := s.repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("id IN ? AND (availability_state IS NULL OR availability_state <> ?)", ids[i:end], "missing").
Updates(map[string]any{"availability_state": "missing", "missing_since": now})
if result.Error != nil {
return changed, result.Error
}
changed += result.RowsAffected
}
if changed > 0 {
s.invalidateMediaCache(ctx)
}
return changed, nil
} }
func pathBelongsToRoot(pathValue, rootPath string) bool { func pathBelongsToRoot(pathValue, rootPath string) bool {
+1 -1
View File
@@ -202,7 +202,7 @@ func (s *ScannerService) scanLocalLibraryFiles(ctx context.Context, lib *model.L
func existingLocalMediaFileIDs(existingMedia map[string]existingLocalMedia) map[string]string { func existingLocalMediaFileIDs(existingMedia map[string]existingLocalMedia) map[string]string {
seenInodes := make(map[string]string) seenInodes := make(map[string]string)
for path, existing := range existingMedia { for path, existing := range existingMedia {
if existing.FileID != "" { if existing.FileID != "" && existing.AvailabilityState != "missing" {
seenInodes[existing.FileID] = path seenInodes[existing.FileID] = path
} }
} }
+10 -3
View File
@@ -175,7 +175,7 @@ func TestIngestPathAdoptsSoftDeletedSTRMRenameTombstone(t *testing.T) {
} }
} }
func TestRemovePathStillHardDeletesTrueDeletionWithoutAlias(t *testing.T) { func TestRemovePathRetainsTrueDeletionWithoutAlias(t *testing.T) {
sc, repos := newScannerTestEnv(t) sc, repos := newScannerTestEnv(t)
root := t.TempDir() root := t.TempDir()
lib := model.Library{Name: "Anime", Path: root, Type: "anime", Enabled: true} lib := model.Library{Name: "Anime", Path: root, Type: "anime", Enabled: true}
@@ -197,8 +197,15 @@ func TestRemovePathStillHardDeletesTrueDeletionWithoutAlias(t *testing.T) {
if err := repos.DB.Unscoped().Model(&model.Media{}).Where("path = ?", path).Count(&count).Error; err != nil { if err := repos.DB.Unscoped().Model(&model.Media{}).Where("path = ?", path).Count(&count).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
if count != 0 { if count != 1 {
t.Fatalf("true deletion left %d tombstone row(s)", count) t.Fatalf("missing media count = %d, want metadata retained", count)
}
var media model.Media
if err := repos.DB.Where("path = ?", path).First(&media).Error; err != nil {
t.Fatal(err)
}
if media.AvailabilityState != "missing" || media.MissingSince == nil {
t.Fatalf("true deletion did not record missing state: %#v", media)
} }
} }
+12
View File
@@ -50,6 +50,7 @@ type SchedulerService struct {
readerFilesTTLHours int readerFilesTTLHours int
segments *MediaSegmentService segments *MediaSegmentService
backup *BackupService
mu sync.Mutex mu sync.Mutex
stopCh chan struct{} stopCh chan struct{}
@@ -65,6 +66,10 @@ func (s *SchedulerService) SetTaskTracker(tasks *TaskTrackerService) {
s.tasks = tasks s.tasks = tasks
} }
func (s *SchedulerService) SetBackupService(backup *BackupService) {
s.backup = backup
}
func (s *SchedulerService) SetOrganizePipeline(pipeline *OrganizePipelineService) { func (s *SchedulerService) SetOrganizePipeline(pipeline *OrganizePipelineService) {
s.organizePipeline = pipeline s.organizePipeline = pipeline
} }
@@ -196,6 +201,13 @@ func (s *SchedulerService) Start(ctx context.Context) {
run: s.jobTelegramExpiryWarning, run: s.jobTelegramExpiryWarning,
}) })
} }
if s.backup != nil {
s.jobs = append(s.jobs, &scheduledJob{
name: "database_backup",
interval: 5 * time.Minute,
run: s.jobDatabaseBackup,
})
}
for _, j := range s.jobs { for _, j := range s.jobs {
initialDelay := 15 * time.Second initialDelay := 15 * time.Second
if j.name == "library_scan" || j.name == "organize_source" || j.name == "segment_prewarm" { if j.name == "library_scan" || j.name == "organize_source" || j.name == "segment_prewarm" {
+47
View File
@@ -11,6 +11,53 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
const lastAutomaticBackupDateKey = "backup.last_automatic_date"
func (s *SchedulerService) jobDatabaseBackup(ctx context.Context) error {
if s == nil || s.backup == nil || s.repo == nil || s.repo.Setting == nil {
return nil
}
enabled, _ := s.repo.Setting.Get(ctx, "backup.auto_enabled")
if !parseBoolSetting(enabled, false) {
return nil
}
clock, _ := s.repo.Setting.Get(ctx, "backup.auto_time")
if clock == "" {
clock = "03:00"
}
parsed, err := time.Parse("15:04", clock)
if err != nil {
return nil
}
now := s.currentTime().In(time.Local)
dueAt := time.Date(now.Year(), now.Month(), now.Day(), parsed.Hour(), parsed.Minute(), 0, 0, now.Location())
if now.Before(dueAt) {
return nil
}
date := now.Format("2006-01-02")
last, _ := s.repo.Setting.Get(ctx, lastAutomaticBackupDateKey)
if last == date {
return nil
}
if _, err := s.backup.CreateAutomatic(ctx); err != nil {
return err
}
// Record the successful backup before pruning. If retention cleanup fails,
// the next scheduler tick must not create another copy for the same day.
if err := s.repo.Setting.Set(ctx, lastAutomaticBackupDateKey, date); err != nil {
return err
}
retentionValue, _ := s.repo.Setting.Get(ctx, "backup.retention")
retention, err := strconv.Atoi(strings.TrimSpace(retentionValue))
if err != nil || retention < 1 {
retention = 7
}
if err := s.backup.Prune(retention); err != nil {
return err
}
return nil
}
// jobScanLibraries re-walks every enabled library. // jobScanLibraries re-walks every enabled library.
// //
// 默认关闭:文件变更由 WatcherService 增量入库,无需周期性全量重扫。 // 默认关闭:文件变更由 WatcherService 增量入库,无需周期性全量重扫。
+4
View File
@@ -63,6 +63,9 @@ func (b *serviceContainerBuilder) startRealtimeServices() {
b.c.WSHub = NewHub(b.log) b.c.WSHub = NewHub(b.log)
helper.Go(b.log, "ws.hub", b.c.WSHub.Run) helper.Go(b.log, "ws.hub", b.c.WSHub.Run)
b.c.Tasks = NewTaskTrackerService(b.log, b.c.WSHub) b.c.Tasks = NewTaskTrackerService(b.log, b.c.WSHub)
if b.repos != nil {
b.c.Tasks.SetPersistence(b.repos.DB)
}
b.c.SystemUpdate = NewSystemUpdateService(b.cfg, b.log, b.repos, b.c.Tasks, b.version) b.c.SystemUpdate = NewSystemUpdateService(b.cfg, b.log, b.repos, b.c.Tasks, b.version)
b.c.SSEHub = NewSSEHub(b.log) b.c.SSEHub = NewSSEHub(b.log)
@@ -206,6 +209,7 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() {
b.c.Organizer, b.c.WSHub, b.cfg.Cache.CacheDir, b.c.Organizer, b.c.WSHub, b.cfg.Cache.CacheDir,
) )
b.c.Scheduler.SetTaskTracker(b.c.Tasks) b.c.Scheduler.SetTaskTracker(b.c.Tasks)
b.c.Scheduler.SetBackupService(b.c.Backup)
b.c.Scheduler.SetOrganizePipeline(b.c.OrganizePipeline) b.c.Scheduler.SetOrganizePipeline(b.c.OrganizePipeline)
b.c.Scheduler.SetSegments(b.c.Segments) b.c.Scheduler.SetSegments(b.c.Segments)
b.c.Scheduler.SetImageCachePolicyProvider(func() ImageCachePolicy { b.c.Scheduler.SetImageCachePolicyProvider(func() ImageCachePolicy {
+79 -1
View File
@@ -2,6 +2,7 @@ package service
import ( import (
"context" "context"
"encoding/json"
"html" "html"
"strings" "strings"
"sync" "sync"
@@ -9,12 +10,16 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
) )
const ( const (
TaskStatusRunning = "running" TaskStatusRunning = "running"
TaskStatusCompleted = "completed" TaskStatusCompleted = "completed"
TaskStatusFailed = "failed" TaskStatusFailed = "failed"
TaskStatusInterrupted = "interrupted"
TaskKindOrganize = "organize" TaskKindOrganize = "organize"
TaskKindScan = "scan" TaskKindScan = "scan"
@@ -70,6 +75,26 @@ type TaskTrackerService struct {
recent []BackgroundTask recent []BackgroundTask
maxRecent int maxRecent int
now func() time.Time now func() time.Time
db *gorm.DB
}
// SetPersistence enables durable task history and marks tasks left running by
// a previous process as interrupted. Actual task execution is never resumed
// automatically; this record is an operator-visible recovery point.
func (t *TaskTrackerService) SetPersistence(db *gorm.DB) {
if t == nil || db == nil {
return
}
t.mu.Lock()
t.db = db
now := t.currentTime()
t.mu.Unlock()
err := db.Model(&model.BackgroundTaskRecord{}).
Where("status = ?", TaskStatusRunning).
Updates(map[string]any{"status": TaskStatusInterrupted, "error": "服务在任务完成前重启;请检查文件状态后重新执行", "updated_at": now, "finished_at": now}).Error
if err != nil && t.log != nil {
t.log.Warn("recover interrupted background tasks failed", zap.Error(err))
}
} }
type TaskHandle struct { type TaskHandle struct {
@@ -105,9 +130,11 @@ func (t *TaskTrackerService) Start(kind, name string, update TaskUpdate) *TaskHa
StartedAt: now, StartedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
applyTaskUpdate(task, update)
t.mu.Lock() t.mu.Lock()
t.active[task.ID] = task t.active[task.ID] = task
snapshot := cloneBackgroundTask(*task) snapshot := cloneBackgroundTask(*task)
t.persist(snapshot)
t.mu.Unlock() t.mu.Unlock()
t.publish(snapshot) t.publish(snapshot)
return &TaskHandle{tracker: t, id: task.ID} return &TaskHandle{tracker: t, id: task.ID}
@@ -132,18 +159,47 @@ func (t *TaskTrackerService) Snapshot() TaskSnapshot {
return TaskSnapshot{} return TaskSnapshot{}
} }
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock()
active := make([]BackgroundTask, 0, len(t.active)) active := make([]BackgroundTask, 0, len(t.active))
for _, task := range t.active { for _, task := range t.active {
active = append(active, cloneBackgroundTask(*task)) active = append(active, cloneBackgroundTask(*task))
} }
recent := make([]BackgroundTask, 0, len(t.recent)) recent := make([]BackgroundTask, 0, len(t.recent))
db := t.db
limit := t.maxRecent
if limit < 1 {
limit = 30
}
for _, task := range t.recent { for _, task := range t.recent {
recent = append(recent, cloneBackgroundTask(task)) recent = append(recent, cloneBackgroundTask(task))
} }
t.mu.Unlock()
if db != nil {
var rows []model.BackgroundTaskRecord
if err := db.Where("status <> ?", TaskStatusRunning).Order("started_at DESC").Limit(limit).Find(&rows).Error; err == nil {
recent = make([]BackgroundTask, 0, len(rows))
for _, row := range rows {
recent = append(recent, backgroundTaskFromRecord(row))
}
} else if t.log != nil {
t.log.Warn("load background task history failed; using memory history", zap.Error(err))
}
}
return TaskSnapshot{Active: active, Recent: recent} return TaskSnapshot{Active: active, Recent: recent}
} }
func backgroundTaskFromRecord(row model.BackgroundTaskRecord) BackgroundTask {
task := BackgroundTask{
ID: row.ID, Kind: row.Kind, Name: row.Name, Status: row.Status,
Stage: row.Stage, SourcePath: row.SourcePath, DestPath: row.DestPath,
Message: row.Message, Error: row.Error, StartedAt: row.StartedAt,
UpdatedAt: row.UpdatedAt, FinishedAt: row.FinishedAt,
}
_ = json.Unmarshal([]byte(row.Details), &task.Details)
_ = json.Unmarshal([]byte(row.Metrics), &task.Metrics)
_ = json.Unmarshal([]byte(row.Items), &task.Items)
return task
}
func (t *TaskTrackerService) update(id string, update TaskUpdate) { func (t *TaskTrackerService) update(id string, update TaskUpdate) {
now := t.currentTime() now := t.currentTime()
t.mu.Lock() t.mu.Lock()
@@ -155,6 +211,7 @@ func (t *TaskTrackerService) update(id string, update TaskUpdate) {
applyTaskUpdate(task, update) applyTaskUpdate(task, update)
task.UpdatedAt = now task.UpdatedAt = now
snapshot := cloneBackgroundTask(*task) snapshot := cloneBackgroundTask(*task)
t.persist(snapshot)
t.mu.Unlock() t.mu.Unlock()
t.publish(snapshot) t.publish(snapshot)
} }
@@ -186,6 +243,7 @@ func (t *TaskTrackerService) finish(id string, err error, update TaskUpdate) {
t.recent = t.recent[:t.maxRecent] t.recent = t.recent[:t.maxRecent]
} }
notifier := t.failureNotifier notifier := t.failureNotifier
t.persist(snapshot)
t.mu.Unlock() t.mu.Unlock()
t.publish(snapshot) t.publish(snapshot)
@@ -195,6 +253,26 @@ func (t *TaskTrackerService) finish(id string, err error, update TaskUpdate) {
} }
} }
func (t *TaskTrackerService) persist(task BackgroundTask) {
if t == nil || t.db == nil {
return
}
encode := func(value any) string {
data, _ := json.Marshal(value)
return string(data)
}
row := model.BackgroundTaskRecord{
ID: task.ID, Kind: task.Kind, Name: task.Name, Status: task.Status,
Stage: task.Stage, SourcePath: task.SourcePath, DestPath: task.DestPath,
Message: task.Message, Error: task.Error, Details: encode(task.Details),
Metrics: encode(task.Metrics), Items: encode(task.Items), StartedAt: task.StartedAt,
UpdatedAt: task.UpdatedAt, FinishedAt: task.FinishedAt,
}
if err := t.db.Save(&row).Error; err != nil && t.log != nil {
t.log.Warn("persist background task failed", zap.String("task_id", task.ID), zap.Error(err))
}
}
// SetFailureNotifier 注入任务失败时的管理员通知回调。 // SetFailureNotifier 注入任务失败时的管理员通知回调。
func (t *TaskTrackerService) SetFailureNotifier(fn func(ctx context.Context, text string)) { func (t *TaskTrackerService) SetFailureNotifier(fn func(ctx context.Context, text string)) {
if t == nil { if t == nil {
@@ -0,0 +1,78 @@
package service
import (
"fmt"
"sync"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
)
func TestTaskTrackerPersistsHistoryAcrossRestart(t *testing.T) {
db := newServiceTestDB(t)
tracker := NewTaskTrackerService(zap.NewNop(), nil)
tracker.SetPersistence(db)
task := tracker.Start(TaskKindScan, "scan", TaskUpdate{Details: []string{"initial detail"}})
task.Update(TaskUpdate{Metrics: map[string]int64{"visited": 42}})
task.Finish(nil, TaskUpdate{Message: "done"})
restarted := NewTaskTrackerService(zap.NewNop(), nil)
restarted.SetPersistence(db)
snapshot := restarted.Snapshot()
if len(snapshot.Active) != 0 || len(snapshot.Recent) != 1 {
t.Fatalf("unexpected history: %#v", snapshot)
}
recent := snapshot.Recent[0]
if recent.Status != TaskStatusCompleted || recent.Metrics["visited"] != 42 || len(recent.Details) != 1 || recent.FinishedAt == nil {
t.Fatalf("task history lost fields: %#v", recent)
}
}
func TestTaskTrackerConcurrentUpdatesDoNotRegressFinishedTasks(t *testing.T) {
db := newServiceTestDB(t)
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
tracker := NewTaskTrackerService(zap.NewNop(), nil)
tracker.SetPersistence(db)
var workers sync.WaitGroup
for i := 0; i < 12; i++ {
workers.Add(1)
go func(index int) {
defer workers.Done()
task := tracker.Start(TaskKindScan, fmt.Sprintf("scan-%d", index), TaskUpdate{})
task.Update(TaskUpdate{Metrics: map[string]int64{"visited": 1}})
task.Finish(nil, TaskUpdate{})
task.Update(TaskUpdate{Message: "late update"})
_ = tracker.Snapshot()
}(i)
}
workers.Wait()
var completed int64
if err := db.Model(&model.BackgroundTaskRecord{}).Where("status = ?", TaskStatusCompleted).Count(&completed).Error; err != nil || completed != 12 {
t.Fatalf("finished task status regressed: completed=%d err=%v", completed, err)
}
if snapshot := tracker.Snapshot(); len(snapshot.Active) != 0 || len(snapshot.Recent) != 12 {
t.Fatalf("inconsistent task snapshot: %#v", snapshot)
}
}
func TestTaskTrackerMarksAbandonedTasksInterrupted(t *testing.T) {
db := newServiceTestDB(t)
tracker := NewTaskTrackerService(zap.NewNop(), nil)
tracker.SetPersistence(db)
tracker.Start(TaskKindOrganize, "unfinished", TaskUpdate{})
restarted := NewTaskTrackerService(zap.NewNop(), nil)
restarted.SetPersistence(db)
snapshot := restarted.Snapshot()
if len(snapshot.Recent) != 1 || snapshot.Recent[0].Status != TaskStatusInterrupted || snapshot.Recent[0].FinishedAt == nil {
t.Fatalf("abandoned task not marked interrupted: %#v", snapshot)
}
var running int64
if err := db.Model(&model.BackgroundTaskRecord{}).Where("status = ?", TaskStatusRunning).Count(&running).Error; err != nil || running != 0 {
t.Fatalf("stale running tasks: %d err=%v", running, err)
}
}
+28
View File
@@ -0,0 +1,28 @@
import { api, LONG_REQUEST_TIMEOUT } from './client'
export interface BackupInfo {
filename: string
size: number
created_at: string
automatic: boolean
}
export const backupAPI = {
list: () => api.get<{ items: BackupInfo[] }>('/admin/backups').then((r) => r.data.items ?? []),
create: () => api.post<BackupInfo>('/admin/backups', {}, { timeout: LONG_REQUEST_TIMEOUT }).then((r) => r.data),
remove: (filename: string) => api.delete('/admin/backups', { params: { filename } }),
restore: (filename: string) => api.post<{ message: string; restart_required: boolean }>('/admin/backups/restore', null, { params: { filename }, timeout: LONG_REQUEST_TIMEOUT }).then((r) => r.data),
download: async (filename: string) => {
const response = await api.get<Blob>('/admin/backups/download', {
params: { filename },
responseType: 'blob',
timeout: LONG_REQUEST_TIMEOUT,
})
const url = URL.createObjectURL(response.data)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
URL.revokeObjectURL(url)
},
}
+14 -1
View File
@@ -262,7 +262,15 @@ export const mediaAPI = {
}) })
.then((r) => r.data), .then((r) => r.data),
searchPage: (q: string, page = 1, pageSize = 50, options?: { groupVersions?: boolean; groupSeries?: boolean }) => searchPage: (q: string, page = 1, pageSize = 50, options?: {
groupVersions?: boolean
groupSeries?: boolean
libraryId?: string
yearMin?: number
yearMax?: number
genres?: string[]
sort?: 'title_asc' | 'year_desc' | 'rating_desc'
}) =>
api api
.get<MediaSearchPage>('/media', { .get<MediaSearchPage>('/media', {
params: { params: {
@@ -271,6 +279,11 @@ export const mediaAPI = {
page_size: pageSize, page_size: pageSize,
group_versions: options?.groupVersions === false ? 0 : undefined, group_versions: options?.groupVersions === false ? 0 : undefined,
...(options?.groupSeries ? { group_series: 1 } : {}), ...(options?.groupSeries ? { group_series: 1 } : {}),
...(options?.libraryId ? { library_id: options.libraryId } : {}),
...(options?.yearMin ? { year_min: options.yearMin } : {}),
...(options?.yearMax ? { year_max: options.yearMax } : {}),
...(options?.genres?.length ? { genre: options.genres } : {}),
...(options?.sort ? { sort: options.sort } : {}),
}, },
timeout: LONG_REQUEST_TIMEOUT, timeout: LONG_REQUEST_TIMEOUT,
}) })
+16
View File
@@ -0,0 +1,16 @@
import { api } from './client'
import type { Media } from '../types'
export type MediaQualityIssue = 'missing' | 'unmatched' | 'poster'
export interface MediaQualityRow {
issue: MediaQualityIssue
media: Media
}
export const qualityAPI = {
list: (issue: MediaQualityIssue, page = 1, pageSize = 50) =>
api.get<{ items: MediaQualityRow[]; total: number; page: number; page_size: number }>('/admin/quality', {
params: { issue, page, page_size: pageSize },
}).then((r) => r.data),
}
+2
View File
@@ -249,6 +249,8 @@ export const readerAPI = {
deleteSource: (id: string) => api.delete(`/reader/sources/${id}`), deleteSource: (id: string) => api.delete(`/reader/sources/${id}`),
debugSource: (id: string, key: string) => debugSource: (id: string, key: string) =>
api.post<{ logs: string[] }>(`/reader/sources/${id}/debug`, { key }, longOpts).then((r) => r.data.logs), api.post<{ logs: string[] }>(`/reader/sources/${id}/debug`, { key }, longOpts).then((r) => r.data.logs),
smokeSource: (id: string, key: string) =>
api.post<{ logs: string[] }>(`/reader/sources/${id}/debug`, { key }, { timeout: 60_000 }).then((r) => r.data.logs),
// ── 书源登录与源变量 ── // ── 书源登录与源变量 ──
// 取登录界面描述(loginUi 控件 + 已保存值 + 当前 Cookie 状态) // 取登录界面描述(loginUi 控件 + 已保存值 + 当前 Cookie 状态)
+27
View File
@@ -0,0 +1,27 @@
import { api } from './client'
export interface BackgroundTask {
id: string
kind: string
name: string
status: 'running' | 'completed' | 'failed' | 'interrupted' | string
stage?: string
source_path?: string
dest_path?: string
message?: string
error?: string
details?: string[]
metrics?: Record<string, number>
started_at: string
updated_at: string
finished_at?: string
}
export interface TaskSnapshot {
active: BackgroundTask[]
recent: BackgroundTask[]
}
export const tasksAPI = {
snapshot: () => api.get<TaskSnapshot>('/admin/tasks').then((r) => r.data),
}
+4
View File
@@ -50,6 +50,8 @@ const StrmUploadQueuePage = lazy(() =>
) )
const ScraperQueuePage = lazy(() => import('./pages/ScraperQueuePage').then((m) => ({ default: m.ScraperQueuePage }))) const ScraperQueuePage = lazy(() => import('./pages/ScraperQueuePage').then((m) => ({ default: m.ScraperQueuePage })))
const TaskQueuePage = lazy(() => import('./pages/TaskQueuePage').then((m) => ({ default: m.TaskQueuePage }))) const TaskQueuePage = lazy(() => import('./pages/TaskQueuePage').then((m) => ({ default: m.TaskQueuePage })))
const MediaQualityPage = lazy(() => import('./pages/MediaQualityPage').then((m) => ({ default: m.MediaQualityPage })))
const SetupWizardPage = lazy(() => import('./pages/SetupWizardPage').then((m) => ({ default: m.SetupWizardPage })))
// 常用页面的路由 chunk 空闲预取:应用加载完成后浏览器一空闲就把浏览路径 // 常用页面的路由 chunk 空闲预取:应用加载完成后浏览器一空闲就把浏览路径
// (库列表/库详情/媒体详情/收藏/历史等)的 chunk 拉下来, // (库列表/库详情/媒体详情/收藏/历史等)的 chunk 拉下来,
@@ -125,5 +127,7 @@ export const appRoutes: AppRoute[] = [
{ path: 'strm/uploads', element: <StrmUploadQueuePage />, adminOnly: true }, { path: 'strm/uploads', element: <StrmUploadQueuePage />, adminOnly: true },
{ path: 'scraper/queue', element: <ScraperQueuePage />, adminOnly: true }, { path: 'scraper/queue', element: <ScraperQueuePage />, adminOnly: true },
{ path: 'queue', element: <TaskQueuePage />, adminOnly: true }, { path: 'queue', element: <TaskQueuePage />, adminOnly: true },
{ path: 'quality', element: <MediaQualityPage />, adminOnly: true },
{ path: 'setup', element: <SetupWizardPage />, adminOnly: true },
{ path: 'admin', element: <AdminPage />, adminOnly: true }, { path: 'admin', element: <AdminPage />, adminOnly: true },
] ]
+5
View File
@@ -89,6 +89,11 @@ export const MediaCard = memo(function MediaCard({
<span>{displayRating.toFixed(1)}</span> <span>{displayRating.toFixed(1)}</span>
</span> </span>
)} )}
{media.availability_state === 'missing' && (
<span className="absolute left-2 top-2 rounded-lg border border-amber-300/30 bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-amber-300 shadow-sm">
文件失联
</span>
)}
{/* Premium Hover Overlay(CSS 过渡替代 framer-motion 逐卡动画实例) */} {/* Premium Hover Overlay(CSS 过渡替代 framer-motion 逐卡动画实例) */}
<div className={`absolute inset-0 bg-gradient-to-t from-[#111827]/90 via-[#111827]/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-end ${ <div className={`absolute inset-0 bg-gradient-to-t from-[#111827]/90 via-[#111827]/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-end ${
+6
View File
@@ -1,6 +1,8 @@
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import { import {
Clock, Clock,
AlertTriangle,
ListChecks as SetupIcon,
FileOutput, FileOutput,
FolderOpen, FolderOpen,
Heart, Heart,
@@ -41,6 +43,8 @@ export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
{ to: '/profile', label: '个人资料', icon: User }, { to: '/profile', label: '个人资料', icon: User },
{ to: '/libraries?from=admin', label: '媒体库', icon: Library }, { to: '/libraries?from=admin', label: '媒体库', icon: Library },
{ to: '/queue', label: '任务队列', icon: ListChecks, adminOnly: true }, { to: '/queue', label: '任务队列', icon: ListChecks, adminOnly: true },
{ to: '/quality', label: '媒体质量', icon: AlertTriangle, adminOnly: true },
{ to: '/setup', label: '首次配置', icon: SetupIcon, adminOnly: true },
{ to: '/admin', label: '用户管理', icon: Users, adminOnly: true }, { to: '/admin', label: '用户管理', icon: Users, adminOnly: true },
{ to: '/files', label: '文件管理', icon: FolderOpen, adminOnly: true }, { to: '/files', label: '文件管理', icon: FolderOpen, adminOnly: true },
{ to: '/emby-mount', label: 'Emby 挂载', icon: Tv, adminOnly: true }, { to: '/emby-mount', label: 'Emby 挂载', icon: Tv, adminOnly: true },
@@ -159,6 +163,8 @@ export function resolveHeaderBack(pathname: string): HeaderBackTarget | null {
pathname === '/admin' || pathname === '/admin' ||
pathname === '/files' || pathname === '/files' ||
pathname === '/queue' || pathname === '/queue' ||
pathname === '/quality' ||
pathname === '/setup' ||
pathname === '/emby-mount' || pathname === '/emby-mount' ||
pathname === '/strm' pathname === '/strm'
) { ) {
+85
View File
@@ -0,0 +1,85 @@
import { useCallback, useEffect, useState } from 'react'
import { Activity, AlertTriangle, CheckCircle2, Clock3, RefreshCw } from 'lucide-react'
import { tasksAPI, type BackgroundTask, type TaskSnapshot } from '../api/tasks'
import { useWebSocket } from '../hooks/useWebSocket'
function statusLabel(status: string) {
switch (status) {
case 'running': return '运行中'
case 'completed': return '已完成'
case 'failed': return '失败'
case 'interrupted': return '进程中断'
default: return status
}
}
function TaskRow({ task }: { task: BackgroundTask }) {
const Icon = task.status === 'running' ? Activity : task.status === 'completed' ? CheckCircle2 : AlertTriangle
const tone = task.status === 'running' ? 'text-blue-500' : task.status === 'completed' ? 'text-emerald-500' : 'text-amber-600'
return (
<article className="rounded-xl border border-sand-200 bg-white/60 p-3">
<div className="flex items-start gap-3">
<Icon size={17} className={`mt-0.5 shrink-0 ${tone}`} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="break-words text-sm font-semibold text-ink-600">{task.name}</h3>
<span className={`text-xs font-semibold ${tone}`}>{statusLabel(task.status)}</span>
</div>
<p className="mt-1 text-xs text-ink-50">{task.message || task.stage || '—'} · {new Date(task.started_at).toLocaleString()}</p>
{task.error && <p className="mt-2 break-words text-xs text-red-500">{task.error}</p>}
{task.details && task.details.length > 0 && (
<details className="mt-2 text-xs text-ink-50"><summary className="cursor-pointer">查看执行详情({task.details.length})</summary>
<ul className="mt-2 space-y-1">{task.details.map((line, index) => <li key={`${index}-${line}`} className="break-words">{line}</li>)}</ul>
</details>
)}
</div>
</div>
</article>
)
}
export function BackgroundTasksPanel() {
const [snapshot, setSnapshot] = useState<TaskSnapshot>({ active: [], recent: [] })
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const refresh = useCallback(async () => {
setLoading(true)
try {
const result = await tasksAPI.snapshot()
setSnapshot({ active: result.active ?? [], recent: result.recent ?? [] })
setError('')
} catch {
setError('读取后台任务失败,请刷新重试')
} finally { setLoading(false) }
}, [])
useEffect(() => { void refresh() }, [refresh])
useWebSocket((topic, payload) => {
if (topic !== 'task' || !payload || typeof payload !== 'object') return
const task = payload as BackgroundTask
if (!task.id || !task.status) return
setSnapshot((current) => {
const active = current.active.filter((item) => item.id !== task.id)
const recent = current.recent.filter((item) => item.id !== task.id)
if (task.status === 'running') active.unshift(task)
else recent.unshift(task)
recent.sort((left, right) => right.started_at.localeCompare(left.started_at))
return { active, recent: recent.slice(0, 30) }
})
})
return (
<section className="glass-panel space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2"><Activity size={18} className="text-brand-500" /><h2 className="font-display text-xl font-bold text-ink-600">后台扫描与整理</h2></div>
<button type="button" className="btn-outline" onClick={() => void refresh()} disabled={loading}><RefreshCw size={14} />刷新</button>
</div>
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
{snapshot.active.length > 0 && <div className="space-y-2"><h3 className="text-xs font-bold uppercase tracking-wide text-ink-50">进行中</h3>{snapshot.active.map((task) => <TaskRow key={task.id} task={task} />)}</div>}
<div className="space-y-2">
<h3 className="flex items-center gap-1 text-xs font-bold uppercase tracking-wide text-ink-50"><Clock3 size={13} />最近任务</h3>
{snapshot.recent.length > 0 ? snapshot.recent.map((task) => <TaskRow key={task.id} task={task} />) : !error && <p className="rounded-xl border border-dashed border-sand-300 px-4 py-6 text-center text-sm text-ink-50">{loading ? '正在读取任务…' : '暂无历史任务'}</p>}
</div>
</section>
)
}
+157
View File
@@ -0,0 +1,157 @@
import { useCallback, useEffect, useState } from 'react'
import { Archive, Download, LoaderCircle, RefreshCw, RotateCcw, Trash2 } from 'lucide-react'
import toast from 'react-hot-toast'
import { backupAPI, type BackupInfo } from '../api/backup'
import { adminAPI } from '../api/admin'
import { confirmAction } from '../components/confirmAction'
function formatBytes(value: number) {
if (value < 1024) return `${value} B`
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`
return `${(value / (1024 * 1024)).toFixed(1)} MB`
}
export function BackupSettingsPanel() {
const [items, setItems] = useState<BackupInfo[]>([])
const [loading, setLoading] = useState(true)
const [creating, setCreating] = useState(false)
const [autoEnabled, setAutoEnabled] = useState(false)
const [autoTime, setAutoTime] = useState('03:00')
const [retention, setRetention] = useState('7')
const [savingPolicy, setSavingPolicy] = useState(false)
const refresh = useCallback(async () => {
setLoading(true)
try {
setItems(await backupAPI.list())
} catch {
toast.error('读取备份列表失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { void refresh() }, [refresh])
useEffect(() => {
void adminAPI.listSettings().then((settings) => {
const values = new Map(settings.map((setting) => [setting.key, setting.value]))
setAutoEnabled(['true', '1'].includes(values.get('backup.auto_enabled') ?? 'false'))
setAutoTime(values.get('backup.auto_time') || '03:00')
setRetention(values.get('backup.retention') || '7')
}).catch(() => toast.error('读取自动备份设置失败'))
}, [])
const savePolicy = async () => {
const count = Number(retention)
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(autoTime) || !Number.isInteger(count) || count < 1 || count > 365) {
toast.error('请填写有效时间,并将保留份数设为 1 到 365')
return
}
setSavingPolicy(true)
try {
await adminAPI.updateSetting('backup.auto_time', autoTime)
await adminAPI.updateSetting('backup.retention', String(count))
await adminAPI.updateSetting('backup.auto_enabled', String(autoEnabled))
toast.success('自动备份设置已保存')
} catch {
toast.error('保存自动备份设置失败')
} finally {
setSavingPolicy(false)
}
}
const create = async () => {
setCreating(true)
try {
const item = await backupAPI.create()
toast.success(`备份已创建:${item.filename}`)
await refresh()
} catch {
toast.error('创建备份失败,请查看服务日志')
} finally {
setCreating(false)
}
}
const remove = async (item: BackupInfo) => {
if (!await confirmAction({ title: '删除备份', message: `确定删除 ${item.filename}?此操作无法撤销。` })) return
try {
await backupAPI.remove(item.filename)
setItems((current) => current.filter((entry) => entry.filename !== item.filename))
toast.success('备份已删除')
} catch {
toast.error('删除备份失败')
}
}
const restore = async (item: BackupInfo) => {
const ok = await confirmAction({
title: '恢复数据库备份',
message: `将恢复 ${item.filename}。SQLite 会先校验并暂存备份,在重启时应用;PostgreSQL 会立即事务恢复。请先保存当前数据库备份,并在操作后重启 MeBox。`,
confirmText: '恢复备份',
danger: true,
})
if (!ok) return
try {
const result = await backupAPI.restore(item.filename)
toast.success(result.message, { duration: 8000 })
} catch (error) {
const message = (error as { response?: { data?: { error?: string } } })?.response?.data?.error
toast.error(message || '恢复失败,请查看服务日志')
}
}
return (
<section className="glass-panel space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Archive className="text-brand-500" size={20} />
<div>
<h2 className="font-display text-xl font-bold text-ink-600">数据库备份</h2>
<p className="text-xs text-ink-50">备份保存在服务器数据目录的 backups 文件夹中;请另行复制到其他设备。</p>
</div>
</div>
<div className="flex gap-2">
<button type="button" className="btn-outline" onClick={() => void refresh()} disabled={loading}><RefreshCw size={14} />刷新</button>
<button type="button" className="btn-primary" onClick={() => void create()} disabled={creating}>
{creating ? <LoaderCircle className="animate-spin" size={14} /> : <Archive size={14} />}立即备份
</button>
</div>
</div>
<div className="grid gap-3 rounded-xl border border-sand-200 p-4 sm:grid-cols-[1fr_auto_auto_auto] sm:items-end">
<label className="flex items-center gap-2 text-sm font-semibold text-ink-600">
<input type="checkbox" checked={autoEnabled} onChange={(event) => setAutoEnabled(event.target.checked)} />
每日自动备份
</label>
<label className="space-y-1 text-xs text-ink-50">执行时间
<input type="time" value={autoTime} onChange={(event) => setAutoTime(event.target.value)} className="block rounded-lg border border-sand-300 bg-white px-3 py-2 text-sm text-ink-600" />
</label>
<label className="space-y-1 text-xs text-ink-50">自动备份保留份数
<input type="number" min={1} max={365} value={retention} onChange={(event) => setRetention(event.target.value)} className="block w-28 rounded-lg border border-sand-300 bg-white px-3 py-2 text-sm text-ink-600" />
</label>
<button type="button" className="btn-outline" onClick={() => void savePolicy()} disabled={savingPolicy}>{savingPolicy ? '保存中…' : '保存策略'}</button>
</div>
{loading ? <div className="py-8 text-center text-sm text-ink-50">正在读取备份…</div> : items.length === 0 ? (
<div className="rounded-xl border border-dashed border-sand-300 px-4 py-8 text-center text-sm text-ink-50">还没有数据库备份</div>
) : (
<div className="divide-y divide-sand-200">
{items.map((item) => (
<div key={item.filename} className="flex flex-wrap items-center justify-between gap-3 py-3">
<div className="min-w-0">
<div className="break-all text-sm font-semibold text-ink-600">{item.filename}</div>
<div className="mt-1 text-xs text-ink-50">{item.automatic ? '自动备份' : '手动备份'} · {new Date(item.created_at).toLocaleString()} · {formatBytes(item.size)}</div>
</div>
<div className="flex flex-wrap gap-2">
<button type="button" className="btn-outline" onClick={() => void backupAPI.download(item.filename).catch(() => toast.error('下载备份失败'))}><Download size={14} />下载</button>
<button type="button" className="btn-outline" onClick={() => void restore(item)}><RotateCcw size={14} />恢复</button>
<button type="button" className="btn-outline text-red-500" onClick={() => void remove(item)}><Trash2 size={14} />删除</button>
</div>
</div>
))}
</div>
)}
</section>
)
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { AlertTriangle, ChevronLeft, ChevronRight, ImageOff, Search, WifiOff } from 'lucide-react'
import { qualityAPI, type MediaQualityIssue, type MediaQualityRow } from '../api/quality'
const tabs: { key: MediaQualityIssue; label: string; icon: typeof AlertTriangle }[] = [
{ key: 'unmatched', label: '未匹配元数据', icon: Search },
{ key: 'poster', label: '缺少海报', icon: ImageOff },
{ key: 'missing', label: '文件失联', icon: WifiOff },
]
const PAGE_SIZE = 50
export function MediaQualityPage() {
const [params, setParams] = useSearchParams()
const rawIssue = params.get('issue')
const issue: MediaQualityIssue = rawIssue === 'poster' || rawIssue === 'missing' ? rawIssue : 'unmatched'
const page = Math.max(1, Number.parseInt(params.get('page') ?? '1', 10) || 1)
const [items, setItems] = useState<MediaQualityRow[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [revision, setRevision] = useState(0)
useEffect(() => {
let cancelled = false
setLoading(true)
setError('')
void qualityAPI.list(issue, page, PAGE_SIZE).then((result) => {
if (!cancelled) { setItems(result.items ?? []); setTotal(result.total ?? 0) }
}).catch(() => {
if (!cancelled) setError('读取质量检查结果失败,请重试')
}).finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [issue, page, revision])
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const changePage = (nextPage: number) => setParams((current) => {
const next = new URLSearchParams(current)
if (nextPage <= 1) next.delete('page')
else next.set('page', String(nextPage))
return next
})
return (
<div className="space-y-5">
<header>
<h1 className="font-display text-3xl font-bold text-ink-600">媒体质量检查</h1>
<p className="mt-1 text-sm text-ink-50">集中查看待匹配、缺少海报和文件失联的媒体条目。</p>
</header>
<nav className="flex flex-wrap gap-2 border-b border-sand-200 pb-3">
{tabs.map((tab) => {
const Icon = tab.icon
return <button key={tab.key} type="button" onClick={() => setParams(tab.key === 'unmatched' ? {} : { issue: tab.key })} className={`inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-semibold ${issue === tab.key ? 'bg-brand-500 text-white' : 'text-ink-100 hover:bg-brand-50'}`}><Icon size={15} />{tab.label}</button>
})}
<span className="ml-auto self-center text-sm text-ink-50">{loading ? '正在检查…' : error ? '读取失败' : `共 ${total} 项`}</span>
</nav>
{error && <div role="alert" className="glass-panel text-sm text-red-500">{error}<button type="button" className="ml-3 underline" onClick={() => setRevision((value) => value + 1)}>重试</button></div>}
{!error && !loading && (items.length === 0 ? <div className="glass-panel py-12 text-center text-sm text-ink-50">{page > 1 ? '本页没有结果,请返回上一页。' : '当前分类没有待处理媒体。'}</div> : (
<div className="divide-y divide-sand-200 overflow-hidden rounded-2xl border border-sand-200 bg-white/60">
{items.map(({ media }) => <article key={media.id} className="flex flex-wrap items-center justify-between gap-3 p-4">
<div className="min-w-0"><Link to={`/media/${media.id}`} className="break-words font-semibold text-ink-600 hover:text-brand-500">{media.title}</Link><p className="mt-1 break-all text-xs text-ink-50">{media.library_name || '未知媒体库'} · {media.path}</p><p className="mt-1 text-xs text-ink-50">{media.year || '年份未知'}{media.scrape_status ? ` · ${media.scrape_status}` : ''}{media.missing_since ? ` · 失联于 ${new Date(media.missing_since).toLocaleString()}` : ''}</p></div>
<Link to={`/media/${media.id}`} className="btn-outline">查看并处理</Link>
</article>)}
</div>
))}
{!loading && !error && (totalPages > 1 || page > 1) && <nav aria-label="媒体质量检查分页" className="flex items-center justify-center gap-4 py-3 text-sm">
<button type="button" className="btn-outline" disabled={page <= 1} onClick={() => changePage(page - 1)}><ChevronLeft size={15} />上一页</button>
<span className="text-ink-50">第 {page} / {totalPages} 页</span>
<button type="button" className="btn-outline" disabled={page >= totalPages} onClick={() => changePage(page + 1)}>下一页<ChevronRight size={15} /></button>
</nav>}
</div>
)
}
+77 -12
View File
@@ -1,14 +1,16 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom' import { useLocation, useSearchParams } from 'react-router-dom'
import { ChevronLeft, ChevronRight, LoaderCircle, RefreshCw, Search } from 'lucide-react' import { ChevronLeft, ChevronRight, LoaderCircle, RefreshCw, Search } from 'lucide-react'
import { mediaAPI, type MediaSearchPage as SearchPageData } from '../api/library' import { mediaAPI, type MediaSearchPage as SearchPageData } from '../api/library'
import { libraryAPI } from '../api/library'
import { MediaCard } from '../components/MediaCard' import { MediaCard } from '../components/MediaCard'
import type { Library } from '../types'
import { PageBackButton } from '../components/PageBackButton' import { PageBackButton } from '../components/PageBackButton'
import { normalizeListPosition } from '../hooks/useListPositionMemory' import { normalizeListPosition } from '../hooks/useListPositionMemory'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { usePlayProfileStore } from '../stores/playProfile' import { usePlayProfileStore } from '../stores/playProfile'
import { favouriteMediaLink, mediaSearchLink } from '../utils/mediaNavigation' import { favouriteMediaLink } from '../utils/mediaNavigation'
const PAGE_SIZE = 48 const PAGE_SIZE = 48
const CACHE_TTL_MS = 5 * 60_000 const CACHE_TTL_MS = 5 * 60_000
@@ -29,40 +31,72 @@ export function MediaSearchPage() {
const [params, setParams] = useSearchParams() const [params, setParams] = useSearchParams()
const query = (params.get('q') ?? '').trim() const query = (params.get('q') ?? '').trim()
const page = normalizeListPosition(params.get('page')) const page = normalizeListPosition(params.get('page'))
const filters = {
libraryId: params.get('library_id') ?? '',
yearMin: params.get('year_min') ?? '',
yearMax: params.get('year_max') ?? '',
genres: params.get('genre') ?? '',
sort: params.get('sort') ?? '',
}
const userId = useAuthStore((s) => s.user?.id) const userId = useAuthStore((s) => s.user?.id)
const profileId = usePlayProfileStore((s) => s.activeProfileId) const profileId = usePlayProfileStore((s) => s.activeProfileId)
const cacheKey = JSON.stringify([userId, profileId, query, page]) const cacheKey = JSON.stringify([userId, profileId, query, page, filters])
const changePage = (next: number) => { const changePage = (next: number) => {
const nextParams = new URLSearchParams({ q: query }) const nextParams = new URLSearchParams(params)
nextParams.set('q', query)
if (next > 1) nextParams.set('page', String(next)) if (next > 1) nextParams.set('page', String(next))
else nextParams.delete('page')
setParams(nextParams) setParams(nextParams)
} }
const changeFilter = (key: keyof typeof filters, value: string) => {
const next = new URLSearchParams(params)
const queryKey = { libraryId: 'library_id', yearMin: 'year_min', yearMax: 'year_max', genres: 'genre', sort: 'sort' }[key]
if (value.trim()) next.set(queryKey, value)
else next.delete(queryKey)
next.delete('page')
setParams(next, { replace: true })
}
return ( return (
<MediaSearchResults <MediaSearchResults
key={cacheKey} key={JSON.stringify([userId, profileId])}
cacheKey={cacheKey} cacheKey={cacheKey}
query={query} query={query}
page={page} page={page}
onPageChange={changePage} onPageChange={changePage}
filters={filters}
onFilterChange={changeFilter}
/> />
) )
} }
function MediaSearchResults({ cacheKey, query, page, onPageChange }: { function MediaSearchResults({ cacheKey, query, page, onPageChange, filters, onFilterChange }: {
cacheKey: string cacheKey: string
query: string query: string
page: number page: number
onPageChange: (page: number) => void onPageChange: (page: number) => void
filters: { libraryId: string; yearMin: string; yearMax: string; genres: string; sort: string }
onFilterChange: (key: keyof { libraryId: string; yearMin: string; yearMax: string; genres: string; sort: string }, value: string) => void
}) { }) {
const location = useLocation()
const [data, setData] = useState(() => readCachedPage(cacheKey)) const [data, setData] = useState(() => readCachedPage(cacheKey))
const [loading, setLoading] = useState(() => !!query && !readCachedPage(cacheKey)) const [loading, setLoading] = useState(() => !!query && !readCachedPage(cacheKey))
const [error, setError] = useState('') const [error, setError] = useState('')
const [revision, setRevision] = useState(0) const [revision, setRevision] = useState(0)
const [libraries, setLibraries] = useState<Library[]>([])
useEffect(() => { void libraryAPI.list().then(setLibraries).catch(() => setLibraries([])) }, [])
useEffect(() => { useEffect(() => {
if (!query) return if (!query) {
setData(null)
setLoading(false)
setError('')
return
}
setError('')
const cached = readCachedPage(cacheKey) const cached = readCachedPage(cacheKey)
if (cached) { if (cached) {
setData(cached) setData(cached)
@@ -72,8 +106,15 @@ function MediaSearchResults({ cacheKey, query, page, onPageChange }: {
let cancelled = false let cancelled = false
setLoading(true) setLoading(true)
setError('') setData(null)
mediaAPI.searchPage(query, page, PAGE_SIZE, { groupSeries: true }) mediaAPI.searchPage(query, page, PAGE_SIZE, {
groupSeries: true,
libraryId: filters.libraryId || undefined,
yearMin: Number(filters.yearMin) || undefined,
yearMax: Number(filters.yearMax) || undefined,
genres: filters.genres.split(/[,,]/).map((value) => value.trim()).filter(Boolean),
sort: filters.sort === 'title_asc' || filters.sort === 'year_desc' || filters.sort === 'rating_desc' ? filters.sort : undefined,
})
.then((result) => { .then((result) => {
if (cancelled) return if (cancelled) return
// 限制缓存大小,避免长时间搜索大量关键词后占用过多内存。 // 限制缓存大小,避免长时间搜索大量关键词后占用过多内存。
@@ -89,7 +130,7 @@ function MediaSearchResults({ cacheKey, query, page, onPageChange }: {
}) })
return () => { cancelled = true } return () => { cancelled = true }
}, [cacheKey, query, page, revision]) }, [cacheKey, query, page, revision, filters.libraryId, filters.yearMin, filters.yearMax, filters.genres, filters.sort])
const refresh = () => { const refresh = () => {
pageCache.delete(cacheKey) pageCache.delete(cacheKey)
@@ -99,7 +140,7 @@ function MediaSearchResults({ cacheKey, query, page, onPageChange }: {
const items = data?.items ?? [] const items = data?.items ?? []
const total = data?.total ?? items.length const total = data?.total ?? items.length
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const from = mediaSearchLink(query, page) const from = location.pathname + location.search
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -126,6 +167,30 @@ function MediaSearchResults({ cacheKey, query, page, onPageChange }: {
)} )}
</div> </div>
{query && (
<div className="grid gap-3 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-4 sm:grid-cols-2 lg:grid-cols-5">
<label className="space-y-1 text-xs text-[var(--app-muted)]">媒体库
<select value={filters.libraryId} onChange={(event) => onFilterChange('libraryId', event.target.value)} className="block w-full rounded-lg border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-sm text-[var(--app-text)]">
<option value="">所有媒体库</option>{libraries.map((library) => <option key={library.id} value={library.id}>{library.name}</option>)}
</select>
</label>
<label className="space-y-1 text-xs text-[var(--app-muted)]">年份从
<input type="number" min={1870} max={2200} value={filters.yearMin} onChange={(event) => onFilterChange('yearMin', event.target.value)} placeholder="不限" className="block w-full rounded-lg border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-sm text-[var(--app-text)]" />
</label>
<label className="space-y-1 text-xs text-[var(--app-muted)]">年份至
<input type="number" min={1870} max={2200} value={filters.yearMax} onChange={(event) => onFilterChange('yearMax', event.target.value)} placeholder="不限" className="block w-full rounded-lg border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-sm text-[var(--app-text)]" />
</label>
<label className="space-y-1 text-xs text-[var(--app-muted)]">类型(逗号分隔)
<input value={filters.genres} onChange={(event) => onFilterChange('genres', event.target.value)} placeholder="动画,科幻" className="block w-full rounded-lg border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-sm text-[var(--app-text)]" />
</label>
<label className="space-y-1 text-xs text-[var(--app-muted)]">排序
<select value={filters.sort} onChange={(event) => onFilterChange('sort', event.target.value)} className="block w-full rounded-lg border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-sm text-[var(--app-text)]">
<option value="">相关度 / 最近添加</option><option value="title_asc">名称 A–Z</option><option value="year_desc">年份从新到旧</option><option value="rating_desc">评分从高到低</option>
</select>
</label>
</div>
)}
{loading && ( {loading && (
<div role="status" className="flex items-center justify-center gap-2 py-20 text-sm text-[var(--app-muted)]"> <div role="status" className="flex items-center justify-center gap-2 py-20 text-sm text-[var(--app-muted)]">
<LoaderCircle size={18} className="animate-spin text-brand-500" /> <LoaderCircle size={18} className="animate-spin text-brand-500" />
@@ -148,7 +213,7 @@ function MediaSearchResults({ cacheKey, query, page, onPageChange }: {
</div> </div>
)} )}
{!loading && items.length > 0 && ( {!loading && !error && query && items.length > 0 && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{items.map((item) => ( {items.map((item) => (
<MediaCard key={item.id} media={item} showLibrary linkTo={favouriteMediaLink(item)} linkState={{ from }} /> <MediaCard key={item.id} media={item} showLibrary linkTo={favouriteMediaLink(item)} linkState={{ from }} />
+95 -1
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom' import { useLocation, useNavigate, useParams } from 'react-router-dom'
import type Hls from 'hls.js' import type Hls from 'hls.js'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { Activity, Clipboard, X } from 'lucide-react'
import { mediaAPI, libraryAPI } from '../api/library' import { mediaAPI, libraryAPI } from '../api/library'
import { cloudHlsURL, hlsURL, postPlaybackProgressKeepalive, stopHLSJob, streamURL } from '../api/client' import { cloudHlsURL, hlsURL, postPlaybackProgressKeepalive, stopHLSJob, streamURL } from '../api/client'
@@ -80,6 +81,8 @@ type PlaybackProgressSession = {
sequence: number sequence: number
} }
type PlayerDiagnosticEvent = { at: string; phase: string; mode: string; message: string }
// 自动跳过片头后,「已跳过 · 撤销」提示停留的时长。 // 自动跳过片头后,「已跳过 · 撤销」提示停留的时长。
const SKIP_NOTICE_MS = 6000 const SKIP_NOTICE_MS = 6000
@@ -148,6 +151,10 @@ export function PlayerPage() {
vr360DirectProxyRef.current = vr360DirectProxy vr360DirectProxyRef.current = vr360DirectProxy
}, [vr360DirectProxy]) }, [vr360DirectProxy])
const [mode, setMode] = useState<PlayerMode>('direct') const [mode, setMode] = useState<PlayerMode>('direct')
const diagnosticModeRef = useRef(mode)
useEffect(() => { diagnosticModeRef.current = mode }, [mode])
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false)
const [diagnosticEvents, setDiagnosticEvents] = useState<PlayerDiagnosticEvent[]>([])
const [subs, setSubs] = useState<SubtitleTrack[]>([]) const [subs, setSubs] = useState<SubtitleTrack[]>([])
const [subtitleIndex, setSubtitleIndex] = useState<number>(0) const [subtitleIndex, setSubtitleIndex] = useState<number>(0)
const authUser = useAuthStore((state) => state.user) const authUser = useAuthStore((state) => state.user)
@@ -498,6 +505,8 @@ export function PlayerPage() {
// 切换视频时重置媒体与弹幕状态,确保新视频自动重新识别并加载弹幕 // 切换视频时重置媒体与弹幕状态,确保新视频自动重新识别并加载弹幕
useEffect(() => { useEffect(() => {
setMedia(null) setMedia(null)
setDiagnosticEvents([])
setDiagnosticsOpen(false)
setLoadError('') setLoadError('')
vr360TouchedRef.current = false vr360TouchedRef.current = false
vr360TextureRetryUsedRef.current = false vr360TextureRetryUsedRef.current = false
@@ -556,6 +565,33 @@ export function PlayerPage() {
setMode(next) setMode(next)
}, []) }, [])
const recordDiagnostic = useCallback((phase: string, message: string) => {
const safeMessage = message
.replace(/https?:\/\/[^\s"'<>]+/gi, '[URL]')
.replace(/\b(token|auth|api[_-]?key|password)=([^\s&]+)/gi, '$1=[redacted]')
setDiagnosticEvents((current) => [...current.slice(-39), {
at: new Date().toISOString(), phase, mode: diagnosticModeRef.current, message: safeMessage.slice(0, 300),
}])
}, [])
useEffect(() => {
const video = ref.current
if (!video) return
const bindings: Array<[string, string]> = [
['loadedmetadata', '已读取媒体信息'],
['playing', '开始播放'],
['waiting', '等待缓冲'],
['stalled', '媒体数据停止到达'],
['seeked', '定位完成'],
]
const listeners = bindings.map(([event, message]) => {
const listener = () => recordDiagnostic(event === 'waiting' || event === 'stalled' ? 'buffer' : 'play', message)
video.addEventListener(event, listener)
return [event, listener] as const
})
return () => { for (const [event, listener] of listeners) video.removeEventListener(event, listener) }
}, [media?.id, recordDiagnostic])
const clearFallbackTimer = useCallback(() => { const clearFallbackTimer = useCallback(() => {
if (fallbackTimerRef.current) { if (fallbackTimerRef.current) {
clearTimeout(fallbackTimerRef.current) clearTimeout(fallbackTimerRef.current)
@@ -569,9 +605,10 @@ export function PlayerPage() {
* 只有画面没有声音」)不走这里,它们播起来之后依然成立。 * 只有画面没有声音」)不走这里,它们播起来之后依然成立。
*/ */
const showPlayFailure = useCallback((message: string) => { const showPlayFailure = useCallback((message: string) => {
recordDiagnostic('error', message)
transientPlayErrorRef.current = message transientPlayErrorRef.current = message
setPlayerError(message) setPlayerError(message)
}, []) }, [recordDiagnostic])
const clearPlayFailure = useCallback(() => { const clearPlayFailure = useCallback(() => {
const stale = transientPlayErrorRef.current const stale = transientPlayErrorRef.current
@@ -1199,6 +1236,7 @@ export function PlayerPage() {
if (Date.now() < directSeekGraceUntilRef.current) return if (Date.now() < directSeekGraceUntilRef.current) return
const current = ref.current const current = ref.current
if (current && current.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return if (current && current.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return
recordDiagnostic('fallback', `直连失败,原因:${reason}`)
if (isRemoteEmbyID(mediaRef.current?.id) || isDirectStreamMedia(mediaRef.current)) { if (isRemoteEmbyID(mediaRef.current?.id) || isDirectStreamMedia(mediaRef.current)) {
const copy = directPlayFailureCopy('remote-emby', reason) const copy = directPlayFailureCopy('remote-emby', reason)
showPlayFailure(copy.banner) showPlayFailure(copy.banner)
@@ -1234,6 +1272,7 @@ export function PlayerPage() {
directOnly, directOnly,
hlsUnavailable, hlsUnavailable,
playbackInfo, playbackInfo,
recordDiagnostic,
selectedQuality, selectedQuality,
setPlaybackMode, setPlaybackMode,
showPlayFailure, showPlayFailure,
@@ -2381,6 +2420,32 @@ export function PlayerPage() {
// 桌面端与横屏手机保持原来的全屏居中沉浸式布局。 // 桌面端与横屏手机保持原来的全屏居中沉浸式布局。
const isMobileTheater = useIsMobileTheater() const isMobileTheater = useIsMobileTheater()
const copyDiagnostics = async () => {
const video = ref.current
const snapshot = {
title: media?.title || '',
sourceType: isRemoteEmbyID(media?.id) ? 'remote_emby' : isStrmMedia(media) ? 'strm' : 'local',
mode,
hlsSource: mode === 'hls' ? hlsSource : undefined,
quality: selectedQuality || undefined,
browser: navigator.userAgent,
video: video ? {
errorCode: video.error?.code ?? null,
readyState: video.readyState,
networkState: video.networkState,
currentTime: Number.isFinite(video.currentTime) ? Math.round(video.currentTime) : null,
duration: Number.isFinite(video.duration) ? Math.round(video.duration) : null,
} : null,
events: diagnosticEvents,
}
try {
await navigator.clipboard.writeText(JSON.stringify(snapshot, null, 2))
toast.success('播放诊断信息已复制(未包含播放地址或登录凭证)')
} catch {
toast.error('复制失败,请检查浏览器剪贴板权限')
}
}
return ( return (
<div className="relative flex h-full w-full flex-1 flex-col overflow-hidden bg-black"> <div className="relative flex h-full w-full flex-1 flex-col overflow-hidden bg-black">
<PlayerTopBar <PlayerTopBar
@@ -2543,6 +2608,35 @@ export function PlayerPage() {
} }
/> />
) : null} ) : null}
<button
type="button"
onClick={() => setDiagnosticsOpen((open) => !open)}
className="fixed bottom-5 right-4 z-[80] inline-flex items-center gap-2 rounded-full border border-white/15 bg-black/75 px-3 py-2 text-xs font-semibold text-white shadow-lg backdrop-blur hover:bg-black/90"
title="播放诊断"
aria-label="播放诊断"
>
<Activity size={15} />诊断
</button>
{diagnosticsOpen && (
<aside className="fixed bottom-16 right-4 z-[80] max-h-[70vh] w-[min(92vw,420px)] overflow-hidden rounded-2xl border border-white/15 bg-[#111827]/95 text-white shadow-2xl backdrop-blur">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
<div><h2 className="text-sm font-bold">播放诊断</h2><p className="mt-1 text-[11px] text-white/60">记录最近 40 条本地播放事件</p></div>
<div className="flex gap-2">
<button type="button" onClick={() => void copyDiagnostics()} className="inline-flex items-center gap-1 rounded-lg bg-white/10 px-2.5 py-1.5 text-xs hover:bg-white/15"><Clipboard size={13} />复制</button>
<button type="button" onClick={() => setDiagnosticsOpen(false)} className="rounded-lg p-1.5 hover:bg-white/10" aria-label="关闭"><X size={16} /></button>
</div>
</div>
<div className="max-h-[55vh] space-y-2 overflow-y-auto p-3">
<div className="rounded-lg bg-white/5 px-3 py-2 text-xs text-white/70">模式:{mode === 'hls' ? `HLS / ${hlsSource}` : '直连'} · 来源:{isRemoteEmbyID(media?.id) ? '远程 Emby' : isStrmMedia(media) ? 'STRM' : '本地文件'}</div>
{diagnosticEvents.length === 0 ? <p className="py-6 text-center text-xs text-white/50">等待播放事件…</p> : [...diagnosticEvents].reverse().map((event, index) => (
<div key={`${event.at}-${index}`} className="rounded-lg bg-white/5 px-3 py-2">
<div className="flex justify-between gap-2 text-[10px] text-white/45"><span>{event.phase} · {event.mode}</span><time>{new Date(event.at).toLocaleTimeString()}</time></div>
<p className="mt-1 break-words text-xs">{event.message}</p>
</div>
))}
</div>
</aside>
)}
</div> </div>
) )
} }
+2
View File
@@ -11,6 +11,7 @@ import { FFToolsPanel } from '../components/FFToolsPanel'
import { AboutSettingsPanel } from './AboutSettingsPanel' import { AboutSettingsPanel } from './AboutSettingsPanel'
import { AdultSettingsPanel } from './AdultSettingsPanel' import { AdultSettingsPanel } from './AdultSettingsPanel'
import { DatabaseSettingsPanel } from './DatabaseSettingsPanel' import { DatabaseSettingsPanel } from './DatabaseSettingsPanel'
import { BackupSettingsPanel } from './BackupSettingsPanel'
import { RecognitionWordsPanel } from './RecognitionWordsPanel' import { RecognitionWordsPanel } from './RecognitionWordsPanel'
import { SettingRow } from './SettingsRow' import { SettingRow } from './SettingsRow'
import { TelegramNotifyPanel } from './TelegramNotifyPanel' import { TelegramNotifyPanel } from './TelegramNotifyPanel'
@@ -172,6 +173,7 @@ export function SettingsPage() {
{!loading && ( {!loading && (
<div className="space-y-4"> <div className="space-y-4">
{group.key === 'database' && <DatabaseSettingsPanel />} {group.key === 'database' && <DatabaseSettingsPanel />}
{group.key === 'backup' && <BackupSettingsPanel />}
{group.key === 'api-configs' && <APIConfigsPanel />} {group.key === 'api-configs' && <APIConfigsPanel />}
{group.key === 'recognition-words' && <RecognitionWordsPanel />} {group.key === 'recognition-words' && <RecognitionWordsPanel />}
{group.key === 'adult' && <AdultSettingsPanel />} {group.key === 'adult' && <AdultSettingsPanel />}
+100
View File
@@ -0,0 +1,100 @@
import { useEffect, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { CheckCircle2, Circle, Database, Film, KeyRound, Server, Wrench } from 'lucide-react'
import { adminAPI, type DatabaseStatus } from '../api/admin'
import { apiConfigsAPI, type APIConfig } from '../api/api_configs'
import { libraryAPI, mediaAPI } from '../api/library'
import { toolsAPI, type FFmpegToolsStatus } from '../api/tools'
import type { Library } from '../types'
import type { SeriesCard } from '../utils/groupSeries'
type CheckResult = { ok: boolean; message: string }
type SetupStepProps = { done: boolean; title: string; detail: string; icon: typeof Database; children?: ReactNode }
function SetupStep({ done, title, detail, icon: Icon, children }: SetupStepProps) {
return (
<section className="glass-panel space-y-3">
<div className="flex items-start gap-3"><div className="mt-0.5 text-brand-500"><Icon size={20} /></div><div className="min-w-0 flex-1"><div className="flex items-center gap-2"><h2 className="font-display text-lg font-bold text-ink-600">{title}</h2>{done ? <CheckCircle2 size={16} className="text-emerald-500" /> : <Circle size={16} className="text-ink-50" />}</div><p className="mt-1 text-sm text-ink-50">{detail}</p></div></div>
{children}
</section>
)
}
export function SetupWizardPage() {
const [database, setDatabase] = useState<DatabaseStatus | null>(null)
const [libraries, setLibraries] = useState<Library[]>([])
const [sources, setSources] = useState<APIConfig[]>([])
const [ffmpeg, setFFmpeg] = useState<FFmpegToolsStatus | null>(null)
const [checks, setChecks] = useState<Record<string, CheckResult>>({})
const [busy, setBusy] = useState('')
const [recent, setRecent] = useState<SeriesCard | null>(null)
const [playbackVerified, setPlaybackVerified] = useState(false)
const refresh = async () => {
setBusy('refresh')
try {
const [db, libs, configs, tools, media] = await Promise.allSettled([
adminAPI.getDatabaseStatus(), libraryAPI.list(), apiConfigsAPI.list(), toolsAPI.ffToolsStatus(), mediaAPI.recent(1),
])
if (db.status === 'fulfilled') setDatabase(db.value)
if (libs.status === 'fulfilled') setLibraries(libs.value)
if (configs.status === 'fulfilled') setSources(configs.value)
if (tools.status === 'fulfilled') setFFmpeg(tools.value)
if (media.status === 'fulfilled') setRecent(media.value[0] ?? null)
const failed = [db, libs, configs, tools, media].some((result) => result.status === 'rejected')
setChecks((value) => ({ ...value, load: { ok: !failed, message: failed ? '部分检查项读取失败,请刷新状态后重试' : '' } }))
} finally {
setBusy('')
}
}
useEffect(() => { void refresh() }, [])
const testSources = async () => {
const enabled = sources.filter((source) => source.enabled)
if (!enabled.length) {
setChecks((value) => ({ ...value, sources: { ok: false, message: '尚未启用元数据源,可先前往管理来源配置' } }))
return
}
setBusy('sources')
const results = await Promise.all(enabled.map(async (source) => {
try { const result = await apiConfigsAPI.test(source.provider); return { name: source.provider, ok: !result.error, message: result.error || result.result } }
catch { return { name: source.provider, ok: false, message: '连接测试失败' } }
}))
const failed = results.filter((result) => !result.ok)
setChecks((value) => ({ ...value, sources: { ok: failed.length === 0, message: failed.length ? `${failed.length}/${results.length} 个来源测试失败:${failed.map((result) => `${result.name}(${result.message})`).join('、')}` : `已完成 ${results.length} 个来源的连接测试` } }))
setBusy('')
}
const scanLibrary = async (library: Library) => {
setBusy(library.id)
try {
const result = await libraryAPI.scan(library.id)
setChecks((value) => ({ ...value, [`scan:${library.id}`]: { ok: !result.queued, message: result.message || (result.queued ? '扫描已提交,请在任务队列确认是否完成' : `扫描完成:新增 ${result.added ?? 0} 项`) } }))
} catch {
setChecks((value) => ({ ...value, [`scan:${library.id}`]: { ok: false, message: '扫描失败,请检查容器内路径和目录权限' } }))
} finally { setBusy('') }
}
return (
<div className="mx-auto max-w-4xl space-y-5">
<header className="flex flex-wrap items-end justify-between gap-3"><div><h1 className="font-display text-3xl font-bold text-ink-600">首次配置向导</h1><p className="mt-1 text-sm text-ink-50">逐项检查配置,完成后可以随时回到对应设置调整。</p></div><button type="button" className="btn-outline" disabled={busy !== ''} onClick={() => void refresh()}>刷新状态</button></header>
{checks.load && !checks.load.ok && <p role="alert" className="glass-panel text-sm text-red-500">{checks.load.message}</p>}
<SetupStep done={Boolean(database)} title="数据库" detail={database ? `${database.type.toUpperCase()} · ${database.open_conns} 个连接` : '正在检查数据库'} icon={Database} />
<SetupStep done={Boolean(ffmpeg?.ffmpeg?.installed && ffmpeg?.ffprobe?.installed)} title="播放工具" detail={ffmpeg?.ffmpeg?.installed ? ffmpeg.ffmpeg.version || 'FFmpeg 已安装' : 'FFmpeg 未安装,浏览器不兼容的媒体无法转码'} icon={Wrench}>
{!ffmpeg?.ffmpeg?.installed && <Link to="/settings?group=general" className="btn-outline inline-flex">前往安装 FFmpeg</Link>}
</SetupStep>
<SetupStep done={Boolean(checks.sources?.ok)} title="元数据源" detail={checks.sources?.message || `${sources.filter((source) => source.enabled).length} 个已启用来源`} icon={KeyRound}>
<div className="flex flex-wrap items-center gap-3"><button type="button" className="btn-outline" disabled={busy !== ''} onClick={() => void testSources()}>{busy === 'sources' ? '测试中…' : '测试已配置来源'}</button><Link to="/settings?group=api-configs" className="text-sm text-brand-500">管理来源</Link></div>
</SetupStep>
<SetupStep done={libraries.length > 0 && libraries.every((library) => library.is_remote_emby || checks[`scan:${library.id}`]?.ok)} title="媒体库与目录" detail={libraries.length ? `已配置 ${libraries.length} 个媒体库;扫描会遍历所选本地库的全部目录,请先确认 Compose 中挂载了正确的容器路径` : '请先创建媒体库并填写容器内路径'} icon={Server}>
{libraries.length ? <div className="space-y-2">{libraries.map((library) => <div key={library.id} className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-sand-200 p-3"><div className="min-w-0"><p className="text-sm font-semibold text-ink-600">{library.name}{library.is_remote_emby && <span className="ml-2 text-xs font-normal text-ink-50">远程只读</span>}</p><p className="break-all text-xs text-ink-50">{library.path}</p>{checks[`scan:${library.id}`] && <p className={`mt-1 text-xs ${checks[`scan:${library.id}`].ok ? 'text-emerald-600' : 'text-red-500'}`}>{checks[`scan:${library.id}`].message}</p>}</div>{!library.is_remote_emby && <button type="button" className="btn-outline" disabled={busy !== ''} onClick={() => void scanLibrary(library)}>{busy === library.id ? '提交中…' : '扫描整个媒体库'}</button>}</div>)}</div> : <Link to="/libraries" className="btn-outline inline-flex">创建媒体库</Link>}
</SetupStep>
<SetupStep done={playbackVerified} title="验证播放" detail={recent ? `最近入库:${recent.rep.title};播放后请确认画面和声音正常` : '先完成媒体库扫描,找到一个可播放条目'} icon={Film}>
{recent && <Link to={`/play/${recent.linkMedia?.id ?? recent.rep.id}`} className="btn-primary inline-flex">播放测试片</Link>}
{recent && <label className="ml-3 inline-flex items-center gap-2 text-sm text-ink-100"><input type="checkbox" checked={playbackVerified} onChange={(event) => setPlaybackVerified(event.target.checked)} />已确认播放正常</label>}
</SetupStep>
<div className="flex flex-wrap gap-3"><Link to="/queue" className="btn-outline">查看任务进度</Link><Link to="/settings" className="btn-outline">打开系统设置</Link></div>
</div>
)
}
+2
View File
@@ -7,6 +7,7 @@ import { scraperAPI } from '../api/scraper'
import { strmAPI } from '../api/strm' import { strmAPI } from '../api/strm'
import { ScraperQueuePage } from './ScraperQueuePage' import { ScraperQueuePage } from './ScraperQueuePage'
import { StrmQueuePanel } from './StrmQueuePage' import { StrmQueuePanel } from './StrmQueuePage'
import { BackgroundTasksPanel } from './BackgroundTasksPanel'
type QueueType = 'all' | 'scrape' | 'download' | 'upload' type QueueType = 'all' | 'scrape' | 'download' | 'upload'
@@ -73,6 +74,7 @@ export function TaskQueuePage() {
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<BackgroundTasksPanel />
<div className="flex flex-wrap items-end justify-between gap-3"> <div className="flex flex-wrap items-end justify-between gap-3">
<div> <div>
<h1 className="font-display text-3xl font-bold text-ink-600">任务队列</h1> <h1 className="font-display text-3xl font-bold text-ink-600">任务队列</h1>
@@ -39,6 +39,9 @@ export default function ReaderSourcesPage() {
const [debugKey, setDebugKey] = useState('') const [debugKey, setDebugKey] = useState('')
const [debugLogs, setDebugLogs] = useState<string[] | null>(null) const [debugLogs, setDebugLogs] = useState<string[] | null>(null)
const [debugging, setDebugging] = useState(false) const [debugging, setDebugging] = useState(false)
const [batchKey, setBatchKey] = useState('')
const [batchTesting, setBatchTesting] = useState(false)
const [batchResults, setBatchResults] = useState<Record<string, { ok: boolean; message: string }>>({})
// 登录面板:记录正在登录的书源 // 登录面板:记录正在登录的书源
const [loginSource, setLoginSource] = useState<ReaderSource | null>(null) const [loginSource, setLoginSource] = useState<ReaderSource | null>(null)
const [loggedInIds, setLoggedInIds] = useState<Record<string, boolean>>({}) const [loggedInIds, setLoggedInIds] = useState<Record<string, boolean>>({})
@@ -122,6 +125,30 @@ export default function ReaderSourcesPage() {
} }
} }
const testEnabledSources = async () => {
const key = batchKey.trim()
const enabled = (sources ?? []).filter((source) => source.enabled)
if (!key) { toast.error('先填写用于检测的书名关键词'); return }
if (!enabled.length) { toast.error('没有已启用的书源'); return }
setBatchTesting(true)
setBatchResults({})
for (let offset = 0; offset < enabled.length; offset += 3) {
const batch = enabled.slice(offset, offset + 3)
const results = await Promise.all(batch.map(async (source) => {
try {
const logs = await readerAPI.smokeSource(source.id, key)
const error = logs.find((line) => line.startsWith('[错误]'))
return [source.id, { ok: !error, message: error ? error.replace(/https?:\/\/[^\s"'<>]+/gi, '[URL]') : '搜索、目录和正文链路通过' }] as const
} catch (error) {
const message = ((error as { response?: { data?: { error?: string } } })?.response?.data?.error || '检测超时或请求失败').replace(/https?:\/\/[^\s"'<>]+/gi, '[URL]')
return [source.id, { ok: false, message }] as const
}
}))
setBatchResults((current) => ({ ...current, ...Object.fromEntries(results) }))
}
setBatchTesting(false)
}
return ( return (
<div className="mx-auto min-h-[100dvh] w-full max-w-4xl px-4 pb-16 pt-4 sm:px-6"> <div className="mx-auto min-h-[100dvh] w-full max-w-4xl px-4 pb-16 pt-4 sm:px-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -205,6 +232,14 @@ export default function ReaderSourcesPage() {
</div> </div>
)} )}
<div className="mt-4 flex flex-wrap items-center gap-2 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-3">
<input value={batchKey} onChange={(event) => setBatchKey(event.target.value)} placeholder="全链路检测关键词,例如:斗破苍穹" className="min-w-48 flex-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-xs text-[var(--app-text)] outline-none" />
<button type="button" className="btn-outline text-xs" disabled={batchTesting || !sources?.some((source) => source.enabled)} onClick={() => void testEnabledSources()}>
{batchTesting ? <><Loader2 size={12} className="mr-1 inline animate-spin" />检测中</> : '检测全部已启用书源'}
</button>
<span className="text-[10px] text-[var(--app-muted)]">每源检查搜索、详情、目录和一章正文,并发最多 3 个</span>
</div>
<div className="mt-6 space-y-2"> <div className="mt-6 space-y-2">
{sources === null && ( {sources === null && (
<div className="flex items-center justify-center py-24 text-[var(--app-muted)]"> <div className="flex items-center justify-center py-24 text-[var(--app-muted)]">
@@ -228,6 +263,7 @@ export default function ReaderSourcesPage() {
{src.group && <span className="truncate text-2xs font-normal text-[var(--app-muted)]">{src.group}</span>} {src.group && <span className="truncate text-2xs font-normal text-[var(--app-muted)]">{src.group}</span>}
</p> </p>
<p className="mt-0.5 truncate text-xs text-[var(--app-muted)]">{hostOf(src.source_url)}</p> <p className="mt-0.5 truncate text-xs text-[var(--app-muted)]">{hostOf(src.source_url)}</p>
{batchResults[src.id] && <p className={`mt-1 truncate text-[10px] ${batchResults[src.id].ok ? 'text-emerald-500' : 'text-red-400'}`}>{batchResults[src.id].message}</p>}
</div> </div>
{/* 登录入口:仅对声明了 loginUrl/loginUi 的书源显示 */} {/* 登录入口:仅对声明了 loginUrl/loginUi 的书源显示 */}
{src.has_login && ( {src.has_login && (
+8
View File
@@ -21,10 +21,18 @@ export const aboutSettingsGroup: SettingGroup = {
items: [], items: [],
} }
export const backupSettingsGroup: SettingGroup = {
key: 'backup',
label: '备份',
description: '创建、下载、恢复和清理数据库备份',
items: [],
}
export const GROUPS: SettingGroup[] = [ export const GROUPS: SettingGroup[] = [
generalSettingsGroup, generalSettingsGroup,
deviceNotifySettingsGroup, deviceNotifySettingsGroup,
databaseSettingsGroup, databaseSettingsGroup,
backupSettingsGroup,
apiConfigsSettingsGroup, apiConfigsSettingsGroup,
recognitionWordsSettingsGroup, recognitionWordsSettingsGroup,
adultSettingsGroup, adultSettingsGroup,
+2
View File
@@ -1,5 +1,7 @@
export interface Media { export interface Media {
id: string id: string
availability_state?: 'available' | 'missing' | string
missing_since?: string
library_id: string library_id: string
library_root_id?: string library_root_id?: string
library_name?: string library_name?: string