feat: 完善媒体维护、备份恢复与播放诊断

This commit is contained in:
jason jia
2026-10-20 14:01:26 +00:00
parent e34e81cce0
commit 1e8fe2396e
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)
if err != nil {
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 (
"net/http"
"path/filepath"
"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 {
return func(c *gin.Context) {
if err := svc.Backup.Restore(c.Request.Context(), c.Query("filename")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
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"
"math"
"net/http"
"sort"
"strconv"
"strings"
"sync"
@@ -654,6 +655,9 @@ func paginateSlice[T any](items []T, page, size int) []T {
if len(items) == 0 {
return []T{}
}
if page-1 > len(items)/size {
return []T{}
}
start := (page - 1) * size
if start >= len(items) {
return []T{}
@@ -668,127 +672,164 @@ func paginateSlice[T any](items []T, page, size int) []T {
func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
q := c.Query("q")
query := strings.TrimSpace(c.Query("q"))
visibility := mediaVisibilityForRequest(c, svc)
groupVersions := c.DefaultQuery("group_versions", "1") != "0"
fetchRemote := func(limit int) []model.Media {
if svc.EmbyRemote == nil || strings.TrimSpace(q) == "" {
return nil
}
remoteItems, _ := svc.EmbyRemote.RemoteSearchMedia(ctx, q, limit, visibility)
return remoteItems
filter := service.MediaSearchFilter{
LibraryID: strings.TrimSpace(c.Query("library_id")),
YearMin: boundedSearchYear(c.Query("year_min")),
YearMax: boundedSearchYear(c.Query("year_max")),
}
if c.DefaultQuery("group_series", "0") != "0" {
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
for _, genre := range c.QueryArray("genre") {
if genre = strings.TrimSpace(genre); genre != "" && len(filter.Genres) < 8 {
filter.Genres = append(filter.Genres, genre)
}
remoteItems := fetchRemote(50000)
all := service.GroupMediaSeriesItems(append(localItems, remoteItems...))
if c.Query("page") != "" || c.Query("page_size") != "" {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
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,
})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
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
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 <= 0 {
if limit < 1 {
limit = 50
} else if limit > 50000 {
limit = 50000
}
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)
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
}
remoteItems := fetchRemote(limit)
all := append(localItems, remoteItems...)
grouped := service.GroupMediaVersions(all)
items := service.FirstMediaItems(grouped, limit)
if items == nil {
items = []service.MediaItem{}
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)
}
}
}
c.JSON(http.StatusOK, gin.H{"items": items})
if c.DefaultQuery("group_series", "0") != "0" {
items = service.GroupMediaSeriesItems(items)
sortSearchMedia(items, sortBy)
writeMediaSearchResults(c, items, page, size, limit, paginated)
return
}
if c.DefaultQuery("group_versions", "1") != "0" {
grouped := service.GroupMediaVersions(items)
if sortBy != "" {
sort.SliceStable(grouped, func(i, j int) bool {
return mediaSearchSortLess(grouped[i].Media, grouped[j].Media, sortBy)
})
}
writeMediaSearchResults(c, grouped, page, size, limit, paginated)
return
}
sortSearchMedia(items, sortBy)
writeMediaSearchResults(c, items, page, size, limit, paginated)
}
}
func writeMediaSearchResults[T any](c *gin.Context, items []T, page, size, limit int, paginated bool) {
if items == nil {
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})
}
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 {
return func(c *gin.Context) {
ctx := c.Request.Context()
+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)
}
})
}
}
+8 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/gin-gonic/gin"
@@ -27,7 +28,8 @@ func TestSearchMediaSeriesKeepsRemoteLibrariesSeparate(t *testing.T) {
"TotalRecordCount": 1,
"Items": []map[string]any{{
"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
}
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)
}
}
+3 -3
View File
@@ -149,10 +149,10 @@ func readerDeleteSourceHandler(svc *service.Container) gin.HandlerFunc {
}
func readerDebugSourceHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Key string `json:"key" binding:"required"`
}
return func(c *gin.Context) {
var body struct {
Key string `json:"key" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
+3
View File
@@ -13,6 +13,8 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.
admin := api.Group("/admin")
admin.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret), middleware.AdminRequired())
registerAdminUserRoutes(admin, svc)
admin.GET("/tasks", backgroundTasksHandler(svc))
admin.GET("/quality", mediaQualityHandler(svc))
registerAdminPermissionRoutes(admin, svc)
registerAdminSystemRoutes(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) {
admin.GET("/backups", listBackupsHandler(svc))
admin.POST("/backups", createBackupHandler(svc))
admin.GET("/backups/download", downloadBackupHandler(svc))
admin.DELETE("/backups", deleteBackupHandler(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"`
}
+11 -6
View File
@@ -1,5 +1,7 @@
package model
import "time"
// Library 表示一个逻辑媒体库。Path 保留为兼容字段,指向第一条 LibraryRoot。
type Library struct {
Base
@@ -26,12 +28,15 @@ type LibraryRoot struct {
// Media 是单个可播放项。剧集链接到 SeriesID;电影 SeriesID == ""。
type Media struct {
Base
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"`
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
Title string `gorm:"size:255;not null" json:"title"`
OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"`
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"`
LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"`
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
Title string `gorm:"size:255;not null" json:"title"`
OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"`
// Path 的唯一性由 database 包按方言显式建索引,这里刻意不用 uniqueIndex:
// PostgreSQL 的 btree 单条索引项上限 2704 字节,而 varchar(1024) 在中文路径
// 下可达 3072 字节,直接对整列建唯一索引会让长路径的扫描插入直接失败
+1
View File
@@ -61,6 +61,7 @@ func AllModels() []interface{} {
&StrmUploadTask{},
&StrmDirCache{},
&ScrapeTask{},
&BackgroundTaskRecord{},
&EmbyMount{},
&ReaderBookSource{},
&ReaderSourceState{},
@@ -5,6 +5,7 @@ import (
"errors"
"path/filepath"
"strings"
"time"
"gorm.io/gorm"
"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.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
return existing.ID, nil
}
@@ -216,6 +223,10 @@ func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.
if m.ScrapeStatus == "" {
m.ScrapeStatus = "pending"
}
now := time.Now()
m.AvailabilityState = "available"
m.MissingSince = nil
m.LastSeenAt = &now
// PostgreSQL 唯一键异常会中止整个事务,之后重查也会失败。
// DO NOTHING 保持事务可用,并兼容 path 的表达式唯一索引。
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 {
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)
addMediaTitleUpdates(updates, existing, incoming)
addMediaExternalIDUpdates(updates, existing, incoming)
+81 -37
View File
@@ -5,7 +5,7 @@
//
// 两种方言的备份形态不同:
// - SQLite:用 "VACUUM INTO"(3.27.0 起可用)产出一个一致的库文件,
// 扩展名 .db;恢复即「替换库文件 + 重启」。
// 扩展名 .db;恢复先暂存,在下次启动打开数据库前替换文件。
// - PostgreSQL:没有单文件语义,用逻辑备份(见 backup_postgres.go),
// 扩展名 .json.gz;恢复是在单个事务内重建全部表。
package service
@@ -24,6 +24,7 @@ import (
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/database"
)
// BackupService manages database backups.
@@ -44,6 +45,7 @@ type BackupInfo struct {
Path string `json:"path"`
Size int64 `json:"size"`
CreatedAt time.Time `json:"created_at"`
Automatic bool `json:"automatic"`
}
func (b *BackupService) backupDir() string {
@@ -69,6 +71,23 @@ func (b *BackupService) backupFilePath(filename string) (string, error) {
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.
//
// 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
// a logical dump.
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()
if err := os.MkdirAll(dir, 0o750); err != nil {
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 (
dst string
@@ -108,15 +139,24 @@ func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) {
Path: dst,
Size: stat.Size(),
CreatedAt: stat.ModTime(),
Automatic: automatic,
}, nil
}
func (b *BackupService) createSQLiteBackup(ctx context.Context, stamp string) (string, error) {
dst := filepath.Join(b.backupDir(), fmt.Sprintf("mebox_%s.db", stamp))
// VACUUM INTO creates a clean, non-WAL copy atomically.
if err := b.db.WithContext(ctx).Exec("VACUUM INTO ?", dst).Error; err != nil {
tmp := dst + ".tmp"
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)
}
if err := os.Chmod(tmp, 0o600); err != nil {
return "", err
}
if err := os.Rename(tmp, dst); err != nil {
return "", err
}
return dst, nil
}
@@ -132,7 +172,7 @@ func (b *BackupService) List() ([]BackupInfo, error) {
}
out := make([]BackupInfo, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !isBackupFilename(e.Name()) {
if !e.Type().IsRegular() || !isBackupFilename(e.Name()) {
continue
}
info, err := e.Info()
@@ -144,6 +184,7 @@ func (b *BackupService) List() ([]BackupInfo, error) {
Path: filepath.Join(dir, e.Name()),
Size: info.Size(),
CreatedAt: info.ModTime(),
Automatic: strings.HasPrefix(e.Name(), "mebox_auto_"),
})
}
sort.Slice(out, func(i, j int) bool {
@@ -152,6 +193,36 @@ func (b *BackupService) List() ([]BackupInfo, error) {
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.
func (b *BackupService) Delete(filename string) error {
path, err := b.backupFilePath(filename)
@@ -161,12 +232,10 @@ func (b *BackupService) Delete(filename string) error {
return os.Remove(path)
}
// Restore replaces the live database with the given backup.
// WARNING: this is destructive. SQLite restores by swapping the database file
// (the caller should shut down the server afterwards); PostgreSQL restores by
// rebuilding every table inside a single transaction.
// Restore stages SQLite backups for the next startup. PostgreSQL restores
// rebuild every table inside a single transaction.
func (b *BackupService) Restore(ctx context.Context, filename string) error {
src, err := b.backupFilePath(filename)
src, err := b.ResolvePath(filename)
if err != nil {
return err
}
@@ -182,35 +251,10 @@ func (b *BackupService) Restore(ctx context.Context, filename string) error {
if !strings.HasSuffix(filename, ".db") {
return errors.New("该备份是 PostgreSQL 逻辑备份,当前运行在 SQLite 上,无法恢复")
}
return b.restoreSQLiteBackup(filename, src)
}
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) {
if err := database.StageSQLiteRestore(ctx, b.cfg, src); err != nil {
return err
}
data, err := os.ReadFile(src) // #nosec G304 -- src is constrained by backupFilePath to the configured backups directory.
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))
b.log.Warn("SQLite backup staged; restart to apply", zap.String("backup", filename))
return nil
}
+33 -12
View File
@@ -3,6 +3,7 @@ package service
import (
"compress/gzip"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
@@ -87,7 +88,8 @@ func postgresBackupModelSchemas(db *gorm.DB) (map[string]*schema.Schema, map[str
// createPostgresBackup 把全库导出成一个 gzip 压缩的 JSON Lines 文件。
func (b *BackupService) createPostgresBackup(ctx context.Context, stamp string) (string, error) {
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 {
return "", err
}
@@ -96,7 +98,7 @@ func (b *BackupService) createPostgresBackup(ctx context.Context, stamp string)
_ = file.Close()
if !complete {
// 半截备份文件比没有备份更危险:留着会让人以为有可用的还原点。
_ = os.Remove(dst)
_ = os.Remove(tmp)
}
}()
@@ -109,17 +111,30 @@ func (b *BackupService) createPostgresBackup(ctx context.Context, stamp string)
}); err != nil {
return "", fmt.Errorf("写入备份头失败: %w", err)
}
for _, m := range model.AllModels() {
table, sch, err := parseModelSchema(b.db, m)
if err != nil {
return "", err
}
if !b.db.Migrator().HasTable(table) {
continue
}
if err := b.dumpPostgresTable(ctx, m, sch, table, enc); err != nil {
return "", err
var options []*sql.TxOptions
if b.isPostgres() {
options = append(options, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
}
// 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
}
if err := snapshot.dumpPostgresTable(ctx, m, sch, table, enc); err != nil {
return err
}
}
return nil
}, options...); err != nil {
return "", err
}
if err := zw.Close(); err != nil {
return "", fmt.Errorf("压缩备份失败: %w", err)
@@ -127,6 +142,12 @@ func (b *BackupService) createPostgresBackup(ctx context.Context, stamp string)
if err := file.Sync(); err != nil {
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
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 (
"context"
"fmt"
"github.com/truewhile/MeBox/internal/model"
"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.
func (s *MediaService) SearchMedia(ctx context.Context, query string, limit int) ([]model.Media, error) {
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) {
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 {
limit = 50
} else if limit > maxMediaSearchLimit {
@@ -23,6 +77,10 @@ func (s *MediaService) SearchMediaVisible(ctx context.Context, query string, lim
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
LibraryID: filter.LibraryID,
YearMin: filter.YearMin,
YearMax: filter.YearMax,
Genres: filter.Genres,
})
if err != nil {
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) {
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 {
pageSize = 50
}
@@ -59,6 +121,10 @@ func (s *MediaService) SearchMediaVisiblePage(ctx context.Context, query string,
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
LibraryID: filter.LibraryID,
YearMin: filter.YearMin,
YearMax: filter.YearMax,
Genres: filter.Genres,
})
if err != nil {
return nil, 0, err
+29 -26
View File
@@ -14,6 +14,7 @@ import (
"errors"
"strings"
"sync"
"time"
"go.uber.org/zap"
@@ -144,32 +145,34 @@ type localMediaProbeTask struct {
}
type existingLocalMedia struct {
LibraryRootID string
RelativePath string
Title string
OriginalName string
EpisodeTitle string
SizeBytes int64
DurationSec int
Width int
Height int
VideoCodec string
AudioCodec string
Container string
STRMURL string
FileID string
PosterURL string
BackdropURL string
Overview string
Year int
ReleaseDate string
Rating float32
TMDbID int
BangumiID int
DoubanID string
TheTVDBID string
SeasonNum int
EpisodeNum int
AvailabilityState string
MissingSince *time.Time
LibraryRootID string
RelativePath string
Title string
OriginalName string
EpisodeTitle string
SizeBytes int64
DurationSec int
Width int
Height int
VideoCodec string
AudioCodec string
Container string
STRMURL string
FileID string
PosterURL string
BackdropURL string
Overview string
Year int
ReleaseDate string
Rating float32
TMDbID int
BangumiID int
DoubanID string
TheTVDBID string
SeasonNum int
EpisodeNum int
// EpisodeFraction 是集号的小数部分(S01E11.5 → 0.5);变化同样要触发刷新,
// 否则已有行上的「半集」标记不会被写回。
EpisodeFraction float64
+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)
}
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()
if err != nil {
return nil, err
@@ -80,6 +80,8 @@ func (s *ScannerService) existingLocalMediaSnapshotForRoot(ctx context.Context,
&row.Languages,
&row.NSFW,
&row.ScrapeStatus,
&row.AvailabilityState,
&row.MissingSince,
); err != nil {
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)
cache := NewRuntimeCacheService(&config.Config{}, zap.NewNop())
sc.SetRuntimeCache(cache)
@@ -589,8 +589,15 @@ func TestRemovePathDeletesVanishedMedia(t *testing.T) {
if removed != 1 {
t.Fatalf("removed = %d, want 1", removed)
}
if countMedia(t, repos) != 0 {
t.Fatal("expected 0 media after removal")
if countMedia(t, repos) != 1 {
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) {
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 {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Path != fileB {
t.Fatalf("remaining rows = %#v, want offline root media preserved", rows)
if len(rows) != 2 {
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]
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
// still needs a backfill probe so installing ffmpeg later repairs the library.
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 {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("media count = %d, want 1", count)
if count != 2 {
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 (
"context"
"errors"
"os"
"path/filepath"
"strings"
@@ -12,11 +13,15 @@ import (
"github.com/truewhile/MeBox/internal/model"
)
// RemovePath 移除磁盘上已不存在的媒体记录。普通的删除仍做物理删除;
// 可确认是 STRM 扩展名改名的路径则先保留软删除墓碑,供后续 ingest 继承元数据。
// RemovePath 将确认不存在的媒体标记为失联;只有可确认的 STRM 扩展名改名
// 才保留软删除墓碑,供后续 ingest 继承元数据。
func (s *ScannerService) RemovePath(ctx context.Context, path string) (int64, error) {
if _, err := os.Stat(path); err == nil {
return 0, nil // still exists; nothing to remove
} 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 行,导致目录
@@ -33,7 +38,7 @@ func (s *ScannerService) RemovePath(ctx context.Context, path string) (int64, er
return 0, err
}
softIDs := make([]string, 0, len(rows))
hardIDs := make([]string, 0, len(rows))
missingIDs := make([]string, 0, len(rows))
for _, row := range rows {
// 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)
// is a rename. Keep a soft-deleted tombstone so the later ingest can
// 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) {
softIDs = append(softIDs, row.ID)
continue
}
hardIDs = append(hardIDs, row.ID)
missingIDs = append(missingIDs, row.ID)
}
var removed int64
if len(softIDs) > 0 {
@@ -60,14 +65,12 @@ func (s *ScannerService) RemovePath(ctx context.Context, path string) (int64, er
}
removed += res.RowsAffected
}
if len(hardIDs) > 0 {
res := s.repo.DB.WithContext(ctx).Unscoped().
Where("id IN ?", hardIDs).
Delete(&model.Media{})
if res.Error != nil {
return removed, res.Error
if len(missingIDs) > 0 {
count, err := s.markMediaMissingByIDs(ctx, missingIDs)
if err != nil {
return removed, err
}
removed += res.RowsAffected
removed += count
}
if removed > 0 {
s.invalidateMediaCache(ctx)
@@ -82,7 +85,7 @@ const strmRenameTombstoneRetention = 7 * 24 * time.Hour
// purgeExpiredSTRMTombstones keeps the soft-delete grace period bounded.
// 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) {
cutoff := time.Now().Add(-strmRenameTombstoneRetention)
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)
}
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) {
@@ -158,7 +161,33 @@ func (s *ScannerService) pruneMissingMediaForRoot(ctx context.Context, libraryID
}
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 {
+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 {
seenInodes := make(map[string]string)
for path, existing := range existingMedia {
if existing.FileID != "" {
if existing.FileID != "" && existing.AvailabilityState != "missing" {
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)
root := t.TempDir()
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 {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("true deletion left %d tombstone row(s)", count)
if count != 1 {
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
segments *MediaSegmentService
backup *BackupService
mu sync.Mutex
stopCh chan struct{}
@@ -65,6 +66,10 @@ func (s *SchedulerService) SetTaskTracker(tasks *TaskTrackerService) {
s.tasks = tasks
}
func (s *SchedulerService) SetBackupService(backup *BackupService) {
s.backup = backup
}
func (s *SchedulerService) SetOrganizePipeline(pipeline *OrganizePipelineService) {
s.organizePipeline = pipeline
}
@@ -196,6 +201,13 @@ func (s *SchedulerService) Start(ctx context.Context) {
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 {
initialDelay := 15 * time.Second
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"
)
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.
//
// 默认关闭:文件变更由 WatcherService 增量入库,无需周期性全量重扫。
+4
View File
@@ -63,6 +63,9 @@ func (b *serviceContainerBuilder) startRealtimeServices() {
b.c.WSHub = NewHub(b.log)
helper.Go(b.log, "ws.hub", b.c.WSHub.Run)
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.SSEHub = NewSSEHub(b.log)
@@ -206,6 +209,7 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() {
b.c.Organizer, b.c.WSHub, b.cfg.Cache.CacheDir,
)
b.c.Scheduler.SetTaskTracker(b.c.Tasks)
b.c.Scheduler.SetBackupService(b.c.Backup)
b.c.Scheduler.SetOrganizePipeline(b.c.OrganizePipeline)
b.c.Scheduler.SetSegments(b.c.Segments)
b.c.Scheduler.SetImageCachePolicyProvider(func() ImageCachePolicy {
+82 -4
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"encoding/json"
"html"
"strings"
"sync"
@@ -9,12 +10,16 @@ import (
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
)
const (
TaskStatusRunning = "running"
TaskStatusCompleted = "completed"
TaskStatusFailed = "failed"
TaskStatusRunning = "running"
TaskStatusCompleted = "completed"
TaskStatusFailed = "failed"
TaskStatusInterrupted = "interrupted"
TaskKindOrganize = "organize"
TaskKindScan = "scan"
@@ -70,6 +75,26 @@ type TaskTrackerService struct {
recent []BackgroundTask
maxRecent int
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 {
@@ -105,9 +130,11 @@ func (t *TaskTrackerService) Start(kind, name string, update TaskUpdate) *TaskHa
StartedAt: now,
UpdatedAt: now,
}
applyTaskUpdate(task, update)
t.mu.Lock()
t.active[task.ID] = task
snapshot := cloneBackgroundTask(*task)
t.persist(snapshot)
t.mu.Unlock()
t.publish(snapshot)
return &TaskHandle{tracker: t, id: task.ID}
@@ -132,18 +159,47 @@ func (t *TaskTrackerService) Snapshot() TaskSnapshot {
return TaskSnapshot{}
}
t.mu.Lock()
defer t.mu.Unlock()
active := make([]BackgroundTask, 0, len(t.active))
for _, task := range t.active {
active = append(active, cloneBackgroundTask(*task))
}
recent := make([]BackgroundTask, 0, len(t.recent))
db := t.db
limit := t.maxRecent
if limit < 1 {
limit = 30
}
for _, task := range t.recent {
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}
}
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) {
now := t.currentTime()
t.mu.Lock()
@@ -155,6 +211,7 @@ func (t *TaskTrackerService) update(id string, update TaskUpdate) {
applyTaskUpdate(task, update)
task.UpdatedAt = now
snapshot := cloneBackgroundTask(*task)
t.persist(snapshot)
t.mu.Unlock()
t.publish(snapshot)
}
@@ -186,6 +243,7 @@ func (t *TaskTrackerService) finish(id string, err error, update TaskUpdate) {
t.recent = t.recent[:t.maxRecent]
}
notifier := t.failureNotifier
t.persist(snapshot)
t.mu.Unlock()
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 注入任务失败时的管理员通知回调。
func (t *TaskTrackerService) SetFailureNotifier(fn func(ctx context.Context, text string)) {
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),
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
.get<MediaSearchPage>('/media', {
params: {
@@ -271,6 +279,11 @@ export const mediaAPI = {
page_size: pageSize,
group_versions: options?.groupVersions === false ? 0 : undefined,
...(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,
})
+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}`),
debugSource: (id: string, key: string) =>
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 状态)
+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 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 拉下来,
@@ -125,5 +127,7 @@ export const appRoutes: AppRoute[] = [
{ path: 'strm/uploads', element: <StrmUploadQueuePage />, adminOnly: true },
{ path: 'scraper/queue', element: <ScraperQueuePage />, 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 },
]
+5
View File
@@ -89,6 +89,11 @@ export const MediaCard = memo(function MediaCard({
<span>{displayRating.toFixed(1)}</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 逐卡动画实例) */}
<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 {
Clock,
AlertTriangle,
ListChecks as SetupIcon,
FileOutput,
FolderOpen,
Heart,
@@ -41,6 +43,8 @@ export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
{ to: '/profile', label: '个人资料', icon: User },
{ to: '/libraries?from=admin', label: '媒体库', icon: Library },
{ 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: '/files', label: '文件管理', icon: FolderOpen, 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 === '/files' ||
pathname === '/queue' ||
pathname === '/quality' ||
pathname === '/setup' ||
pathname === '/emby-mount' ||
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 { useSearchParams } from 'react-router-dom'
import { useLocation, useSearchParams } from 'react-router-dom'
import { ChevronLeft, ChevronRight, LoaderCircle, RefreshCw, Search } from 'lucide-react'
import { mediaAPI, type MediaSearchPage as SearchPageData } from '../api/library'
import { libraryAPI } from '../api/library'
import { MediaCard } from '../components/MediaCard'
import type { Library } from '../types'
import { PageBackButton } from '../components/PageBackButton'
import { normalizeListPosition } from '../hooks/useListPositionMemory'
import { useAuthStore } from '../stores/auth'
import { usePlayProfileStore } from '../stores/playProfile'
import { favouriteMediaLink, mediaSearchLink } from '../utils/mediaNavigation'
import { favouriteMediaLink } from '../utils/mediaNavigation'
const PAGE_SIZE = 48
const CACHE_TTL_MS = 5 * 60_000
@@ -29,40 +31,72 @@ export function MediaSearchPage() {
const [params, setParams] = useSearchParams()
const query = (params.get('q') ?? '').trim()
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 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 nextParams = new URLSearchParams({ q: query })
const nextParams = new URLSearchParams(params)
nextParams.set('q', query)
if (next > 1) nextParams.set('page', String(next))
else nextParams.delete('page')
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 (
<MediaSearchResults
key={cacheKey}
key={JSON.stringify([userId, profileId])}
cacheKey={cacheKey}
query={query}
page={page}
onPageChange={changePage}
filters={filters}
onFilterChange={changeFilter}
/>
)
}
function MediaSearchResults({ cacheKey, query, page, onPageChange }: {
function MediaSearchResults({ cacheKey, query, page, onPageChange, filters, onFilterChange }: {
cacheKey: string
query: string
page: number
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 [loading, setLoading] = useState(() => !!query && !readCachedPage(cacheKey))
const [error, setError] = useState('')
const [revision, setRevision] = useState(0)
const [libraries, setLibraries] = useState<Library[]>([])
useEffect(() => { void libraryAPI.list().then(setLibraries).catch(() => setLibraries([])) }, [])
useEffect(() => {
if (!query) return
if (!query) {
setData(null)
setLoading(false)
setError('')
return
}
setError('')
const cached = readCachedPage(cacheKey)
if (cached) {
setData(cached)
@@ -72,8 +106,15 @@ function MediaSearchResults({ cacheKey, query, page, onPageChange }: {
let cancelled = false
setLoading(true)
setError('')
mediaAPI.searchPage(query, page, PAGE_SIZE, { groupSeries: true })
setData(null)
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) => {
if (cancelled) return
// 限制缓存大小,避免长时间搜索大量关键词后占用过多内存。
@@ -89,7 +130,7 @@ function MediaSearchResults({ cacheKey, query, page, onPageChange }: {
})
return () => { cancelled = true }
}, [cacheKey, query, page, revision])
}, [cacheKey, query, page, revision, filters.libraryId, filters.yearMin, filters.yearMax, filters.genres, filters.sort])
const refresh = () => {
pageCache.delete(cacheKey)
@@ -99,7 +140,7 @@ function MediaSearchResults({ cacheKey, query, page, onPageChange }: {
const items = data?.items ?? []
const total = data?.total ?? items.length
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const from = mediaSearchLink(query, page)
const from = location.pathname + location.search
return (
<div className="space-y-6">
@@ -126,6 +167,30 @@ function MediaSearchResults({ cacheKey, query, page, onPageChange }: {
)}
</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 && (
<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" />
@@ -148,7 +213,7 @@ function MediaSearchResults({ cacheKey, query, page, onPageChange }: {
</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">
{items.map((item) => (
<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 type Hls from 'hls.js'
import toast from 'react-hot-toast'
import { Activity, Clipboard, X } from 'lucide-react'
import { mediaAPI, libraryAPI } from '../api/library'
import { cloudHlsURL, hlsURL, postPlaybackProgressKeepalive, stopHLSJob, streamURL } from '../api/client'
@@ -80,6 +81,8 @@ type PlaybackProgressSession = {
sequence: number
}
type PlayerDiagnosticEvent = { at: string; phase: string; mode: string; message: string }
// 自动跳过片头后,「已跳过 · 撤销」提示停留的时长。
const SKIP_NOTICE_MS = 6000
@@ -148,6 +151,10 @@ export function PlayerPage() {
vr360DirectProxyRef.current = vr360DirectProxy
}, [vr360DirectProxy])
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 [subtitleIndex, setSubtitleIndex] = useState<number>(0)
const authUser = useAuthStore((state) => state.user)
@@ -498,6 +505,8 @@ export function PlayerPage() {
// 切换视频时重置媒体与弹幕状态,确保新视频自动重新识别并加载弹幕
useEffect(() => {
setMedia(null)
setDiagnosticEvents([])
setDiagnosticsOpen(false)
setLoadError('')
vr360TouchedRef.current = false
vr360TextureRetryUsedRef.current = false
@@ -556,6 +565,33 @@ export function PlayerPage() {
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(() => {
if (fallbackTimerRef.current) {
clearTimeout(fallbackTimerRef.current)
@@ -569,9 +605,10 @@ export function PlayerPage() {
* 只有画面没有声音」)不走这里,它们播起来之后依然成立。
*/
const showPlayFailure = useCallback((message: string) => {
recordDiagnostic('error', message)
transientPlayErrorRef.current = message
setPlayerError(message)
}, [])
}, [recordDiagnostic])
const clearPlayFailure = useCallback(() => {
const stale = transientPlayErrorRef.current
@@ -1199,6 +1236,7 @@ export function PlayerPage() {
if (Date.now() < directSeekGraceUntilRef.current) return
const current = ref.current
if (current && current.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return
recordDiagnostic('fallback', `直连失败,原因:${reason}`)
if (isRemoteEmbyID(mediaRef.current?.id) || isDirectStreamMedia(mediaRef.current)) {
const copy = directPlayFailureCopy('remote-emby', reason)
showPlayFailure(copy.banner)
@@ -1234,6 +1272,7 @@ export function PlayerPage() {
directOnly,
hlsUnavailable,
playbackInfo,
recordDiagnostic,
selectedQuality,
setPlaybackMode,
showPlayFailure,
@@ -2381,6 +2420,32 @@ export function PlayerPage() {
// 桌面端与横屏手机保持原来的全屏居中沉浸式布局。
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 (
<div className="relative flex h-full w-full flex-1 flex-col overflow-hidden bg-black">
<PlayerTopBar
@@ -2543,6 +2608,35 @@ export function PlayerPage() {
}
/>
) : 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>
)
}
+2
View File
@@ -11,6 +11,7 @@ import { FFToolsPanel } from '../components/FFToolsPanel'
import { AboutSettingsPanel } from './AboutSettingsPanel'
import { AdultSettingsPanel } from './AdultSettingsPanel'
import { DatabaseSettingsPanel } from './DatabaseSettingsPanel'
import { BackupSettingsPanel } from './BackupSettingsPanel'
import { RecognitionWordsPanel } from './RecognitionWordsPanel'
import { SettingRow } from './SettingsRow'
import { TelegramNotifyPanel } from './TelegramNotifyPanel'
@@ -172,6 +173,7 @@ export function SettingsPage() {
{!loading && (
<div className="space-y-4">
{group.key === 'database' && <DatabaseSettingsPanel />}
{group.key === 'backup' && <BackupSettingsPanel />}
{group.key === 'api-configs' && <APIConfigsPanel />}
{group.key === 'recognition-words' && <RecognitionWordsPanel />}
{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>
)
}
+3 -1
View File
@@ -7,6 +7,7 @@ import { scraperAPI } from '../api/scraper'
import { strmAPI } from '../api/strm'
import { ScraperQueuePage } from './ScraperQueuePage'
import { StrmQueuePanel } from './StrmQueuePage'
import { BackgroundTasksPanel } from './BackgroundTasksPanel'
type QueueType = 'all' | 'scrape' | 'download' | 'upload'
@@ -73,6 +74,7 @@ export function TaskQueuePage() {
return (
<div className="space-y-5">
<BackgroundTasksPanel />
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">任务队列</h1>
@@ -145,4 +147,4 @@ export function TaskQueuePage() {
)}
</div>
)
}
}
@@ -39,6 +39,9 @@ export default function ReaderSourcesPage() {
const [debugKey, setDebugKey] = useState('')
const [debugLogs, setDebugLogs] = useState<string[] | null>(null)
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 [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 (
<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">
@@ -205,6 +232,14 @@ export default function ReaderSourcesPage() {
</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">
{sources === null && (
<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>}
</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>
{/* 登录入口:仅对声明了 loginUrl/loginUi 的书源显示 */}
{src.has_login && (
+8
View File
@@ -21,10 +21,18 @@ export const aboutSettingsGroup: SettingGroup = {
items: [],
}
export const backupSettingsGroup: SettingGroup = {
key: 'backup',
label: '备份',
description: '创建、下载、恢复和清理数据库备份',
items: [],
}
export const GROUPS: SettingGroup[] = [
generalSettingsGroup,
deviceNotifySettingsGroup,
databaseSettingsGroup,
backupSettingsGroup,
apiConfigsSettingsGroup,
recognitionWordsSettingsGroup,
adultSettingsGroup,
+2
View File
@@ -1,5 +1,7 @@
export interface Media {
id: string
availability_state?: 'available' | 'missing' | string
missing_since?: string
library_id: string
library_root_id?: string
library_name?: string