mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-13 19:54:23 +08:00
Compare commits
7 Commits
beta
..
mebox-v0.2.8
| Author | SHA1 | Date | |
|---|---|---|---|
| 3057664a48 | |||
| 7793d50906 | |||
| 5ad71b9421 | |||
| 5615806c2c | |||
| 1e8fe2396e | |||
| e34e81cce0 | |||
| 3cf898756b |
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
+257
-113
@@ -4,8 +4,10 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -40,6 +42,11 @@ type webLibraryPayload struct {
|
||||
Cards []service.SeriesCardView `json:"cards,omitempty"`
|
||||
}
|
||||
|
||||
// remoteLibraryPreviewBudget 限制 /api/libraries?with_preview=1 里远程 Emby
|
||||
// 库预览(总条数 + 最新卡片)的总耗时。这些预览只用于首页卡片,值得为它
|
||||
// 排队等待的量很小;此前它跟随请求上下文,上游超时会让整页等 15 秒以上。
|
||||
var remoteLibraryPreviewBudget = 3 * time.Second
|
||||
|
||||
// remoteLibraryItemTypes 远程库内容拉取时按 CollectionType 过滤直属条目,
|
||||
// 避免电影库里的合集文件夹(Folder) 漏出为电影卡片。
|
||||
func remoteLibraryItemTypes(collectionType string) string {
|
||||
@@ -176,6 +183,12 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
|
||||
}
|
||||
if withPreview && len(allowedViews) > 0 {
|
||||
// 远程库预览只是首页卡片上的「最新几条」,绝不能拖住整个
|
||||
// 库列表:上游 Emby 一旦超时,每个远程库都要等满
|
||||
// embyRemoteHTTPTimeout(15s),串起来就是 17–30 秒。
|
||||
// 给整段预览一个总预算,超时就只返回库名,卡片留给下一次刷新。
|
||||
previewCtx, cancelPreview := context.WithTimeout(ctx, remoteLibraryPreviewBudget)
|
||||
defer cancelPreview()
|
||||
const maxRemotePreviewWorkers = 6
|
||||
sem := make(chan struct{}, maxRemotePreviewWorkers)
|
||||
var wg sync.WaitGroup
|
||||
@@ -187,11 +200,11 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
defer func() { <-sem }()
|
||||
case <-ctx.Done():
|
||||
case <-previewCtx.Done():
|
||||
return
|
||||
}
|
||||
helper.Run(svc.Log, "media.remotePreview", func() {
|
||||
acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID)
|
||||
acct := svc.EmbyRemote.AccountByID(previewCtx, v.AccountID)
|
||||
if acct == nil {
|
||||
return
|
||||
}
|
||||
@@ -203,10 +216,10 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
Name: v.Library.Name,
|
||||
}
|
||||
itemTypes := remoteLibraryItemTypes(v.CollectionType)
|
||||
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
|
||||
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(previewCtx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
|
||||
remotePayloads[i].Total = total
|
||||
}
|
||||
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
|
||||
if cards, err := svc.EmbyRemote.RemoteLatestCards(previewCtx, tmpMount, acct, v.RemoteID, limit); err == nil {
|
||||
remotePayloads[i].Cards = service.NewSeriesCardViews(cards)
|
||||
}
|
||||
})
|
||||
@@ -556,19 +569,17 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
})
|
||||
return
|
||||
}
|
||||
grouped, err := svc.Media.GroupedMediaVisibleFiltered(ctx, id, mediaVisibilityForRequest(c, svc), filters)
|
||||
items, total, err := svc.Media.ListMediaVisibleSortedPage(ctx, id, page, size, mediaVisibilityForRequest(c, svc), filters, sortSpec, history)
|
||||
if err != nil {
|
||||
writeInternalOrCanceled(c, err)
|
||||
return
|
||||
}
|
||||
grouped = service.SortMediaItems(grouped, sortSpec.Field, sortSpec.Order, history)
|
||||
items := service.PaginateMediaItems(grouped, page, size)
|
||||
if items == nil {
|
||||
items = []service.MediaItem{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"total": len(grouped),
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": size,
|
||||
})
|
||||
@@ -656,6 +667,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{}
|
||||
@@ -670,127 +684,257 @@ 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"))
|
||||
// 远程搜索与本地查询并行发起:本地结果不再排在远程超时之后才返回。
|
||||
remoteCh := startRemoteMediaSearch(ctx, svc, query, visibility, filter)
|
||||
// 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 remoteCh != nil {
|
||||
for _, item := range <-remoteCh {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
// remoteSearchItemLimit 是每个远程 Emby 挂载点返回的条目上限。此前这里传
|
||||
// 50000(等于让上游做一次全库查询),上游稍慢就会一路跑到 5 秒超时,把每次
|
||||
// 搜索都拖成秒级;搜索场景只需要少量候选,多出来的结果也会被分页丢掉。
|
||||
remoteSearchItemLimit = 100
|
||||
// remoteSearchBudget 限制远程结果最多给一次请求增加多少延迟。
|
||||
remoteSearchBudget = 2 * time.Second
|
||||
// remoteSearchCacheTTL 让同一关键词的翻页与重复搜索复用远程结果,避免每翻
|
||||
// 一页都重新打一遍远程挂载点。
|
||||
remoteSearchCacheTTL = 30 * time.Second
|
||||
// remoteSearchFailureCacheTTL 用于上游超时/失败时的短缓存:部分结果只保留
|
||||
// 几秒,避免反复吃满 remoteSearchBudget,又不至于长期压住后续重试。
|
||||
remoteSearchFailureCacheTTL = 5 * time.Second
|
||||
)
|
||||
|
||||
// startRemoteMediaSearch 并发发起远程搜索,返回一个只投递一次结果的通道。
|
||||
// 不满足远程搜索条件时返回 nil。通道带缓冲,调用方即使提前返回也不会泄漏
|
||||
// goroutine;请求结束时请求上下文被取消,远程调用会随之退出。
|
||||
func startRemoteMediaSearch(
|
||||
ctx context.Context,
|
||||
svc *service.Container,
|
||||
query string,
|
||||
visibility service.MediaVisibility,
|
||||
filter service.MediaSearchFilter,
|
||||
) <-chan []model.Media {
|
||||
if svc == nil || svc.EmbyRemote == nil || query == "" {
|
||||
return nil
|
||||
}
|
||||
if filter.LibraryID != "" && !service.IsEmbyRemoteID(filter.LibraryID) {
|
||||
return nil
|
||||
}
|
||||
results := make(chan []model.Media, 1)
|
||||
go func() {
|
||||
results <- remoteMediaSearchItems(ctx, svc, query, visibility, filter)
|
||||
}()
|
||||
return results
|
||||
}
|
||||
|
||||
func remoteMediaSearchItems(
|
||||
ctx context.Context,
|
||||
svc *service.Container,
|
||||
query string,
|
||||
visibility service.MediaVisibility,
|
||||
filter service.MediaSearchFilter,
|
||||
) []model.Media {
|
||||
cacheKey := remoteSearchCacheKey(query, visibility)
|
||||
if svc.Cache != nil {
|
||||
if cached, ok := svc.Cache.GetObject(cacheKey); ok {
|
||||
if rows, ok := cached.([]model.Media); ok {
|
||||
return filterRemoteSearchItems(rows, filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
searchCtx, cancel := context.WithTimeout(ctx, remoteSearchBudget)
|
||||
defer cancel()
|
||||
remote, _ := svc.EmbyRemote.RemoteSearchMedia(searchCtx, query, remoteSearchItemLimit, visibility)
|
||||
if svc.Cache != nil {
|
||||
// 空结果也要缓存:否则查不到片名时每次都会重新等到 remoteSearchBudget。
|
||||
if remote == nil {
|
||||
remote = []model.Media{}
|
||||
}
|
||||
ttl := remoteSearchCacheTTL
|
||||
if searchCtx.Err() != nil {
|
||||
ttl = remoteSearchFailureCacheTTL
|
||||
}
|
||||
svc.Cache.SetObject(cacheKey, remote, ttl)
|
||||
}
|
||||
return filterRemoteSearchItems(remote, filter)
|
||||
}
|
||||
|
||||
func filterRemoteSearchItems(remote []model.Media, filter service.MediaSearchFilter) []model.Media {
|
||||
if len(remote) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]model.Media, 0, len(remote))
|
||||
for _, item := range remote {
|
||||
if remoteSearchMatchesFilter(item, filter) {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// remoteSearchCacheKey 按可见范围隔离远程搜索结果,避免把某个用户可见挂载
|
||||
// 点的结果复用给可见范围不同的用户。
|
||||
func remoteSearchCacheKey(query string, visibility service.MediaVisibility) string {
|
||||
allowed := append([]string(nil), visibility.AllowedLibraryIDs...)
|
||||
hidden := append([]string(nil), visibility.HiddenLibraryIDs...)
|
||||
sort.Strings(allowed)
|
||||
sort.Strings(hidden)
|
||||
return fmt.Sprintf("media:obj:remote-search:%t|%s|%s|%s",
|
||||
visibility.IncludeNSFW, strings.Join(allowed, ","), strings.Join(hidden, ","), query)
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// TestListLibrariesRemotePreviewIsBounded 锁定远程库预览的总预算:
|
||||
// 上游 Emby 卡住时,/api/libraries?with_preview=1 必须按时返回远程库条目
|
||||
// (只是没有卡片),而不是跟着上游一起等十几秒。线上实测这个接口因此出现过
|
||||
// 17.4 秒、30 秒(前端放弃)的响应,设置页也跟着整页空转。
|
||||
func TestListLibrariesRemotePreviewIsBounded(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
oldBudget := remoteLibraryPreviewBudget
|
||||
remoteLibraryPreviewBudget = 200 * time.Millisecond
|
||||
t.Cleanup(func() { remoteLibraryPreviewBudget = oldBudget })
|
||||
|
||||
// Views / 用户列表正常返回,Preview(Items 查询)永远不返回:模拟上游半挂。
|
||||
release := make(chan struct{})
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
path := r.URL.Path
|
||||
switch {
|
||||
case strings.HasSuffix(path, "/Views"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"Items": []map[string]any{{
|
||||
"Id": "view-1", "Name": "远程动画", "CollectionType": "tvshows",
|
||||
}},
|
||||
})
|
||||
return
|
||||
case strings.HasSuffix(path, "/Users"):
|
||||
// api_key 账号要先解析出远程用户 Id,否则请求路径会落到兜底 "0"。
|
||||
_ = json.NewEncoder(w).Encode([]map[string]any{{"Id": "uid-1", "Name": "admin"}})
|
||||
return
|
||||
case r.Method == http.MethodPost:
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"AccessToken": "fake-token",
|
||||
"User": map[string]any{"Id": "uid-1"},
|
||||
})
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-release:
|
||||
case <-r.Context().Done():
|
||||
case <-time.After(30 * time.Second):
|
||||
}
|
||||
w.WriteHeader(http.StatusGatewayTimeout)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
defer close(release)
|
||||
|
||||
// 唯一库名 + cache=shared:预览协程会并发读库(账号解析)。
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.StrmAccount{}, &model.EmbyMount{}, &model.Setting{}, &model.User{}, &model.PlayProfile{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := db.Create(&model.User{Base: model.Base{ID: "admin-1"}, Username: "admin", Role: "admin"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rawConfig, _ := json.Marshal(map[string]string{"url": upstream.URL, "token": "fake-token"})
|
||||
acct := model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-1"}, Name: "远程Emby", Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig), Enabled: true,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), &acct); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mount := model.EmbyMount{
|
||||
Base: model.Base{ID: "mount-1"}, AccountID: acct.ID, RemoteViewID: "view-1",
|
||||
RemoteViewName: "远程动画", CollectionType: "tvshows", Enabled: true,
|
||||
}
|
||||
if err := repos.EmbyMount.Create(t.Context(), &mount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
|
||||
EmbyRemote: service.NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, service.NewCryptoService("", zap.NewNop())),
|
||||
Cache: service.NewRuntimeCacheService(&config.Config{}, zap.NewNop()),
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set(middleware.CtxUserID, "admin-1")
|
||||
c.Set(middleware.CtxUserRole, "admin")
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/libraries?with_preview=1&include_hidden=1", nil)
|
||||
|
||||
start := time.Now()
|
||||
listLibrariesHandler(svc)(c)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if elapsed > 2*time.Second {
|
||||
t.Fatalf("request took %v with a hung upstream; the preview budget must bound it", elapsed)
|
||||
}
|
||||
var payload []webLibraryPayload
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v (%s)", err, w.Body.String())
|
||||
}
|
||||
var remote *webLibraryPayload
|
||||
for i := range payload {
|
||||
if payload[i].IsRemoteEmby {
|
||||
remote = &payload[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if remote == nil {
|
||||
t.Fatalf("remote library missing from the bounded response: %#v", payload)
|
||||
}
|
||||
if len(remote.Cards) != 0 || remote.Total != 0 {
|
||||
t.Fatalf("expected the preview to be skipped on timeout, got total=%d cards=%d", remote.Total, len(remote.Cards))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"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"
|
||||
)
|
||||
|
||||
// TestSearchMediaRemoteSearchBoundedAndCached 防止远程搜索再次退化:
|
||||
// 1. 每个挂载点的 Limit 必须是 remoteSearchItemLimit,而不是历史全库查询的 50000;
|
||||
// 2. 同一关键词的重复搜索应命中缓存,不再打上游;
|
||||
// 3. 指定本地媒体库时不应枚举远程挂载点。
|
||||
func TestSearchMediaRemoteSearchBoundedAndCached(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
var mu sync.Mutex
|
||||
searchCalls := 0
|
||||
searchLimits := make([]string, 0, 4)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if term := r.URL.Query().Get("SearchTerm"); term != "" {
|
||||
mu.Lock()
|
||||
searchCalls++
|
||||
searchLimits = append(searchLimits, r.URL.Query().Get("Limit"))
|
||||
mu.Unlock()
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"TotalRecordCount": 1,
|
||||
"Items": []map[string]any{{
|
||||
"Id": "remote-1",
|
||||
"Name": "远方剧集",
|
||||
"Type": "Series",
|
||||
"ProductionYear": 2024,
|
||||
}},
|
||||
})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
// 唯一库名 + cache=shared:远程搜索与本地查询会并发读库,
|
||||
// 而 :memory: 下每个连接各自一个空库,并发连接会读到「no such table」。
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.StrmAccount{}, &model.EmbyMount{}, &model.Setting{}, &model.User{}, &model.PlayProfile{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.DB.Create(&model.User{Base: model.Base{ID: "user-1"}, Username: "admin", Role: "admin"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lib := model.Library{Name: "本地电影", Path: "/media/movies", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "local-1"}, LibraryID: lib.ID, Title: "流浪地球", Path: "/media/movies/流浪地球.mkv",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rawConfig, _ := json.Marshal(map[string]string{"url": upstream.URL, "token": "fake-token"})
|
||||
acct := model.StrmAccount{
|
||||
Base: model.Base{ID: "acct"}, Name: "远程Emby", Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig), Enabled: true,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), &acct); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mount := model.EmbyMount{
|
||||
Base: model.Base{ID: "mount-1"}, AccountID: acct.ID, RemoteViewID: "view-1",
|
||||
RemoteViewName: "远程剧集", CollectionType: "tvshows", Enabled: true,
|
||||
}
|
||||
if err := repos.EmbyMount.Create(t.Context(), &mount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
|
||||
EmbyRemote: service.NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, service.NewCryptoService("", zap.NewNop())),
|
||||
Cache: service.NewRuntimeCacheService(&config.Config{}, zap.NewNop()),
|
||||
}
|
||||
|
||||
search := func(params string) []service.MediaItem {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set(middleware.CtxUserID, "user-1")
|
||||
c.Set(middleware.CtxUserRole, "admin")
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/media?"+params, nil)
|
||||
searchMediaHandler(svc)(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
Items []service.MediaItem `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return response.Items
|
||||
}
|
||||
callCount := func() int {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return searchCalls
|
||||
}
|
||||
|
||||
if items := search("q=远方&page=1&page_size=48"); len(items) != 1 || items[0].Title != "远方剧集" {
|
||||
t.Fatalf("expected remote result, got %#v", items)
|
||||
}
|
||||
if calls := callCount(); calls != 1 {
|
||||
t.Fatalf("expected exactly one upstream search, got %d", calls)
|
||||
}
|
||||
mu.Lock()
|
||||
limit := searchLimits[0]
|
||||
mu.Unlock()
|
||||
if limit != "100" {
|
||||
t.Fatalf("remote search Limit=%q, want bounded 100 (50000 means a full-library query)", limit)
|
||||
}
|
||||
|
||||
// 同一关键词再来一次(例如刷新/重复搜索)应复用缓存,不再请求上游。
|
||||
if items := search("q=远方&page=1&page_size=48"); len(items) != 1 || items[0].Title != "远方剧集" {
|
||||
t.Fatalf("cached search lost the remote result: %#v", items)
|
||||
}
|
||||
if calls := callCount(); calls != 1 {
|
||||
t.Fatalf("expected cached remote search to skip upstream, got %d calls", calls)
|
||||
}
|
||||
|
||||
// 指定本地媒体库时不应枚举远程挂载点。
|
||||
if items := search("q=流浪&page=1&page_size=48&library_id=" + lib.ID); len(items) != 1 || items[0].ID != "local-1" {
|
||||
t.Fatalf("expected local-only result, got %#v", items)
|
||||
}
|
||||
if calls := callCount(); calls != 1 {
|
||||
t.Fatalf("local library filter must not hit upstream, got %d calls", calls)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -27,13 +29,17 @@ 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"},
|
||||
}},
|
||||
})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
// 唯一库名 + cache=shared:远程搜索与本地查询会并发读库,
|
||||
// 而 :memory: 下每个连接各自一个空库,并发连接会读到「no such table」。
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -108,4 +114,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,7 +559,10 @@ func TestSearchMediaHandlerIncludesEmbyRemote(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
// 唯一库名 + cache=shared:远程搜索与本地查询会并发读库,
|
||||
// 而 :memory: 下每个连接各自一个空库,并发连接会读到「no such table」。
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ func registerAuthedDLNARoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
}
|
||||
|
||||
func registerAuthedRealtimeRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/ws", wsHandler(svc))
|
||||
authed.GET("/events", sseHandler(svc))
|
||||
// These streams carry operator tasks, including filesystem paths and scan
|
||||
// errors. Their payloads are not scoped to an individual user's libraries.
|
||||
authed.GET("/ws", middleware.AdminRequired(), wsHandler(svc))
|
||||
authed.GET("/events", middleware.AdminRequired(), sseHandler(svc))
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// Package handler — WebSocket endpoint.
|
||||
//
|
||||
// Clients connect to /api/ws?token=... (the token is the same JWT used for
|
||||
// REST calls). The first message they send is a JSON {"action":"subscribe",
|
||||
// "topics":["scan","scrape","transcode"]}. Subsequent server-pushed events
|
||||
// arrive as {"topic":"...","payload":{...}}.
|
||||
// Administrators connect to /api/ws?token=... with their REST access token.
|
||||
// Operator events arrive as {"topic":"...","payload":{...}}.
|
||||
package handler
|
||||
|
||||
import (
|
||||
@@ -52,8 +50,11 @@ func wsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
sub := svc.WSHub.Subscribe(id, nil)
|
||||
defer svc.WSHub.Unsubscribe(id)
|
||||
|
||||
// Reader: accept ping + subscription updates.
|
||||
// Reading processes WebSocket control frames and detects disconnects.
|
||||
done := make(chan struct{})
|
||||
conn.SetReadLimit(64 << 10)
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
if _, _, err := conn.NextReader(); err != nil {
|
||||
_ = conn.Close()
|
||||
@@ -67,6 +68,8 @@ func wsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case ev, ok := <-sub.Out:
|
||||
if !ok {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func realtimeTestRouter(hub *service.Hub) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
authed := router.Group("/api")
|
||||
authed.Use(middleware.AuthRequired("realtime-test"))
|
||||
registerAuthedRealtimeRoutes(authed, &service.Container{WSHub: hub})
|
||||
return router
|
||||
}
|
||||
|
||||
func realtimeTestToken(t *testing.T, role string) string {
|
||||
t.Helper()
|
||||
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, middleware.Claims{
|
||||
UserID: "viewer", Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour))},
|
||||
}).SignedString([]byte("realtime-test"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func TestRealtimeStreamsRejectOrdinaryUsers(t *testing.T) {
|
||||
router := realtimeTestRouter(nil)
|
||||
for _, path := range []string{"/api/ws", "/api/events"} {
|
||||
for _, role := range []string{"", "user"} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
want := http.StatusUnauthorized
|
||||
if role != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+realtimeTestToken(t, role))
|
||||
want = http.StatusForbidden
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, req)
|
||||
if recorder.Code != want {
|
||||
t.Fatalf("%s role %q: status %d, want %d", path, role, recorder.Code, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminWebSocketReceivesTaskEvents(t *testing.T) {
|
||||
hub := service.NewHub(zap.NewNop())
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
server := httptest.NewServer(realtimeTestRouter(hub))
|
||||
defer server.Close()
|
||||
url := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/ws?token=" + realtimeTestToken(t, "admin")
|
||||
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
stop := make(chan struct{})
|
||||
defer close(stop)
|
||||
go func() {
|
||||
ticker := time.NewTicker(10 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
hub.Publish("task", service.BackgroundTask{ID: "task-1", SourcePath: "/media/private"})
|
||||
}
|
||||
}
|
||||
}()
|
||||
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
var event service.Event
|
||||
if err := conn.ReadJSON(&event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event.Topic != "task" || event.Payload.(map[string]any)["source_path"] != "/media/private" {
|
||||
t.Fatalf("event = %#v", event)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 字节,直接对整列建唯一索引会让长路径的扫描插入直接失败
|
||||
|
||||
@@ -61,6 +61,7 @@ func AllModels() []interface{} {
|
||||
&StrmUploadTask{},
|
||||
&StrmDirCache{},
|
||||
&ScrapeTask{},
|
||||
&BackgroundTaskRecord{},
|
||||
&EmbyMount{},
|
||||
&ReaderBookSource{},
|
||||
&ReaderSourceState{},
|
||||
|
||||
@@ -178,14 +178,6 @@ func (r *MediaRepository) YearRange(ctx context.Context, filter MediaQueryFilter
|
||||
return min, max, nil
|
||||
}
|
||||
|
||||
func (r *MediaRepository) indexMediaBestEffort(ctx context.Context, media model.Media) {
|
||||
backend, ok := r.searchBackend.(MediaSearchSyncBackend)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_ = backend.IndexMedia(ctx, []model.Media{media})
|
||||
}
|
||||
|
||||
// FindByID returns the media row or (nil, nil).
|
||||
func (r *MediaRepository) FindByID(ctx context.Context, id string) (*model.Media, error) {
|
||||
var m model.Media
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
@@ -96,15 +97,34 @@ func (r *MediaRepository) UpsertBatchWithAliases(ctx context.Context, items []Me
|
||||
|
||||
// indexByIDBestEffort 在事务提交后按 ID 回读最新行并同步搜索索引。
|
||||
func (r *MediaRepository) indexByIDBestEffort(ctx context.Context, ids []string) {
|
||||
if _, ok := r.searchBackend.(MediaSearchSyncBackend); !ok {
|
||||
backend, ok := r.searchBackend.(MediaSearchSyncBackend)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
unique := make([]string, 0, len(ids))
|
||||
seen := make(map[string]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if fresh, err := r.FindByID(ctx, id); err == nil && fresh != nil {
|
||||
r.indexMediaBestEffort(ctx, *fresh)
|
||||
if _, exists := seen[id]; !exists {
|
||||
seen[id] = struct{}{}
|
||||
unique = append(unique, id)
|
||||
}
|
||||
}
|
||||
// Read committed rows and submit one bulk per bounded batch. Keep network
|
||||
// work outside the transaction and below SQLite's parameter limit.
|
||||
const batchSize = 500
|
||||
for start := 0; start < len(unique); start += batchSize {
|
||||
batch := unique[start:min(start+batchSize, len(unique))]
|
||||
rows, err := r.ListMediaByIDs(ctx, batch)
|
||||
if err == nil {
|
||||
err = backend.IndexMedia(ctx, rows)
|
||||
}
|
||||
if err != nil {
|
||||
// The primary write has committed; do not report a failed upsert and
|
||||
// make scanners repeat it. Surface the separate index failure instead.
|
||||
r.db.Logger.Error(ctx, "media saved but search index sync failed for %d items: %v", len(batch), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,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
|
||||
}
|
||||
@@ -197,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)
|
||||
@@ -246,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)
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -135,14 +137,113 @@ func (b *OpenSearchMediaBackend) EnsureIndex(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (b *OpenSearchMediaBackend) IndexMedia(ctx context.Context, rows []model.Media) error {
|
||||
const batchSize = 500
|
||||
var failures []error
|
||||
for start := 0; start < len(rows); start += batchSize {
|
||||
if err := b.indexMediaBatch(ctx, rows[start:min(start+batchSize, len(rows))]); err != nil {
|
||||
failures = append(failures, err)
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.Join(failures...)
|
||||
}
|
||||
|
||||
type openSearchBulkResponse struct {
|
||||
Errors bool `json:"errors"`
|
||||
Items []struct {
|
||||
Index struct {
|
||||
ID string `json:"_id"`
|
||||
Status int `json:"status"`
|
||||
Error *struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"error"`
|
||||
} `json:"index"`
|
||||
} `json:"items"`
|
||||
}
|
||||
|
||||
func (b *OpenSearchMediaBackend) indexMediaBatch(ctx context.Context, rows []model.Media) error {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
pending := rows
|
||||
var failures []error
|
||||
const attempts = 3
|
||||
for attempt := 0; attempt < attempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return errors.Join(append(failures, err)...)
|
||||
}
|
||||
if attempt > 0 {
|
||||
timer := time.NewTimer(time.Duration(100*(1<<(attempt-1))) * time.Millisecond)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return errors.Join(append(failures, ctx.Err())...)
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
response, err := b.submitMediaBulk(ctx, pending)
|
||||
if err != nil {
|
||||
if retryableOpenSearchError(err) && attempt < attempts-1 {
|
||||
continue
|
||||
}
|
||||
return errors.Join(append(failures, err)...)
|
||||
}
|
||||
if len(response.Items) != len(pending) {
|
||||
return errors.Join(append(failures, fmt.Errorf("opensearch bulk returned %d item results for %d documents", len(response.Items), len(pending)))...)
|
||||
}
|
||||
var retry []model.Media
|
||||
failed := false
|
||||
for i, item := range response.Items {
|
||||
result := item.Index
|
||||
if result.Status >= 200 && result.Status < 300 && result.Error == nil {
|
||||
continue
|
||||
}
|
||||
failed = true
|
||||
if retryableOpenSearchStatus(result.Status) && attempt < attempts-1 {
|
||||
retry = append(retry, pending[i])
|
||||
continue
|
||||
}
|
||||
errorType := "unknown"
|
||||
if result.Error != nil {
|
||||
errorType = result.Error.Type
|
||||
}
|
||||
failures = append(failures, fmt.Errorf("opensearch bulk document %q failed: status %d (%s)", pending[i].ID, result.Status, errorType))
|
||||
}
|
||||
if response.Errors && !failed {
|
||||
failures = append(failures, errors.New("opensearch bulk reported errors without failed item results"))
|
||||
}
|
||||
if len(retry) == 0 {
|
||||
break
|
||||
}
|
||||
pending = retry
|
||||
}
|
||||
return errors.Join(failures...)
|
||||
}
|
||||
|
||||
func retryableOpenSearchStatus(status int) bool {
|
||||
return status == http.StatusRequestTimeout || status == http.StatusTooManyRequests || status >= 500
|
||||
}
|
||||
|
||||
func retryableOpenSearchError(err error) bool {
|
||||
var statusError *openSearchHTTPError
|
||||
if errors.As(err, &statusError) {
|
||||
return retryableOpenSearchStatus(statusError.status)
|
||||
}
|
||||
var networkError net.Error
|
||||
return errors.As(err, &networkError)
|
||||
}
|
||||
|
||||
func (b *OpenSearchMediaBackend) submitMediaBulk(ctx context.Context, rows []model.Media) (*openSearchBulkResponse, error) {
|
||||
if len(rows) == 0 {
|
||||
return &openSearchBulkResponse{}, nil
|
||||
}
|
||||
var bulk bytes.Buffer
|
||||
enc := json.NewEncoder(&bulk)
|
||||
for _, row := range rows {
|
||||
if err := enc.Encode(map[string]any{"index": map[string]any{"_index": b.index, "_id": row.ID}}); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if err := enc.Encode(map[string]any{
|
||||
"id": row.ID,
|
||||
@@ -155,10 +256,24 @@ func (b *OpenSearchMediaBackend) IndexMedia(ctx context.Context, rows []model.Me
|
||||
"deleted": row.DeletedAt.Valid,
|
||||
"created_at": row.CreatedAt,
|
||||
}); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return b.do(ctx, http.MethodPost, "/_bulk", &bulk, "application/x-ndjson", nil)
|
||||
var response openSearchBulkResponse
|
||||
if err := b.do(ctx, http.MethodPost, "/_bulk", &bulk, "application/x-ndjson", &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
type openSearchHTTPError struct {
|
||||
method string
|
||||
path string
|
||||
status int
|
||||
}
|
||||
|
||||
func (e *openSearchHTTPError) Error() string {
|
||||
return fmt.Sprintf("opensearch %s %s returned %d", e.method, e.path, e.status)
|
||||
}
|
||||
|
||||
func (b *OpenSearchMediaBackend) doJSON(ctx context.Context, method, path string, body any, out any) error {
|
||||
@@ -190,7 +305,7 @@ func (b *OpenSearchMediaBackend) do(ctx context.Context, method, path string, bo
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("opensearch %s %s returned %d", method, path, resp.StatusCode)
|
||||
return &openSearchHTTPError{method: method, path: path, status: resp.StatusCode}
|
||||
}
|
||||
if out == nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
func TestOpenSearchMediaBackendSearchesIDs(t *testing.T) {
|
||||
@@ -55,3 +66,156 @@ func TestOpenSearchMediaBackendSearchesIDs(t *testing.T) {
|
||||
t.Fatalf("ids=%#v total=%d", ids, total)
|
||||
}
|
||||
}
|
||||
|
||||
func bulkRequestIDs(r *http.Request) []string {
|
||||
var ids []string
|
||||
scanner := bufio.NewScanner(r.Body)
|
||||
for scanner.Scan() {
|
||||
var action struct {
|
||||
Index struct {
|
||||
ID string `json:"_id"`
|
||||
} `json:"index"`
|
||||
}
|
||||
_ = json.Unmarshal(scanner.Bytes(), &action)
|
||||
ids = append(ids, action.Index.ID)
|
||||
if !scanner.Scan() {
|
||||
break
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func writeBulkResults(w http.ResponseWriter, ids []string, statuses map[string]int) {
|
||||
items := make([]any, 0, len(ids))
|
||||
failed := false
|
||||
for _, id := range ids {
|
||||
status := statuses[id]
|
||||
if status == 0 {
|
||||
status = http.StatusCreated
|
||||
}
|
||||
result := map[string]any{"_id": id, "status": status}
|
||||
if status >= 400 {
|
||||
failed = true
|
||||
result["error"] = map[string]any{"type": "test_failure"}
|
||||
}
|
||||
items = append(items, map[string]any{"index": result})
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"errors": failed, "items": items})
|
||||
}
|
||||
|
||||
func TestOpenSearchBulkRetriesOnlyTransientFailedDocuments(t *testing.T) {
|
||||
var requests [][]string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ids := bulkRequestIDs(r)
|
||||
requests = append(requests, ids)
|
||||
statuses := map[string]int{}
|
||||
if len(requests) == 1 {
|
||||
statuses["retry"] = http.StatusTooManyRequests
|
||||
statuses["invalid"] = http.StatusBadRequest
|
||||
}
|
||||
writeBulkResults(w, ids, statuses)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
backend := NewOpenSearchMediaBackend(config.SearchConfig{Backend: "opensearch", OpenSearchURL: upstream.URL})
|
||||
rows := []model.Media{{Base: model.Base{ID: "ok"}}, {Base: model.Base{ID: "retry"}}, {Base: model.Base{ID: "invalid"}}}
|
||||
err := backend.IndexMedia(t.Context(), rows)
|
||||
if err == nil || !strings.Contains(err.Error(), `"invalid"`) {
|
||||
t.Fatalf("error = %v, want permanent document failure", err)
|
||||
}
|
||||
if len(requests) != 2 || strings.Join(requests[1], ",") != "retry" {
|
||||
t.Fatalf("requests = %#v; successful/permanent documents must not be retried", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSearchBulkBoundsRetriesAndRejectsMalformedResponses(t *testing.T) {
|
||||
for _, scenario := range []string{"transient-item", "transient-http", "invalid-http", "missing-results"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
calls := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
switch scenario {
|
||||
case "transient-item":
|
||||
writeBulkResults(w, bulkRequestIDs(r), map[string]int{"m1": http.StatusServiceUnavailable})
|
||||
case "transient-http":
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
case "invalid-http":
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
case "missing-results":
|
||||
fmt.Fprint(w, `{"errors":false,"items":[]}`)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
backend := NewOpenSearchMediaBackend(config.SearchConfig{Backend: "opensearch", OpenSearchURL: upstream.URL})
|
||||
if err := backend.IndexMedia(t.Context(), []model.Media{{Base: model.Base{ID: "m1"}}}); err == nil {
|
||||
t.Fatal("failed indexing was reported as successful")
|
||||
}
|
||||
want := 1
|
||||
if strings.HasPrefix(scenario, "transient") {
|
||||
want = 3
|
||||
}
|
||||
if calls != want {
|
||||
t.Fatalf("calls = %d, want %d", calls, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSearchBulkHonorsCancellationDuringRetry(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
calls := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
writeBulkResults(w, bulkRequestIDs(r), map[string]int{"m1": http.StatusTooManyRequests})
|
||||
cancel()
|
||||
}))
|
||||
defer upstream.Close()
|
||||
backend := NewOpenSearchMediaBackend(config.SearchConfig{Backend: "opensearch", OpenSearchURL: upstream.URL})
|
||||
err := backend.IndexMedia(ctx, []model.Media{{Base: model.Base{ID: "m1"}}})
|
||||
if !errors.Is(err, context.Canceled) || calls != 1 {
|
||||
t.Fatalf("error = %v, calls = %d", err, calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaUpsertBatchIndexesCommittedRowsTogether(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
defer sqlDB.Close()
|
||||
if err := db.AutoMigrate(&model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := New(db)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
calls := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
ids := bulkRequestIDs(r)
|
||||
if len(ids) != 5 {
|
||||
t.Errorf("bulk contains %d documents, want 5", len(ids))
|
||||
}
|
||||
// With only one DB connection this also detects network work inside the
|
||||
// transaction: a separate read could not observe the committed batch.
|
||||
var count int64
|
||||
if err := db.WithContext(ctx).Model(&model.Media{}).Count(&count).Error; err != nil || count != 5 {
|
||||
t.Errorf("committed rows = %d, error = %v", count, err)
|
||||
}
|
||||
writeBulkResults(w, ids, nil)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
repos.Media.SetSearchBackend(NewOpenSearchMediaBackend(config.SearchConfig{Backend: "opensearch", OpenSearchURL: upstream.URL}))
|
||||
var rows []*model.Media
|
||||
for i := 0; i < 5; i++ {
|
||||
rows = append(rows, &model.Media{LibraryID: "lib", Path: fmt.Sprintf("/media/%d.mkv", i)})
|
||||
}
|
||||
if err := repos.Media.UpsertBatch(ctx, rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("index requests = %d, want one bulk", calls)
|
||||
}
|
||||
}
|
||||
|
||||
+81
-37
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,10 @@ type ImageProxy struct {
|
||||
resizeSemMu sync.Mutex
|
||||
resizeSem chan struct{}
|
||||
|
||||
// resizeQueueWait 是一次请求最多为一格解码槽排队的时长。为 0 时用
|
||||
// defaultResizeQueueWait;测试会把它调小以免真的等待两秒。
|
||||
resizeQueueWait time.Duration
|
||||
|
||||
// libraryRootsFn returns the configured media library roots so that
|
||||
// sidecar poster/artwork files stored alongside media (under arbitrary
|
||||
// per-library paths) are allowed by isAllowedLocalPath. It is provided
|
||||
@@ -86,6 +90,12 @@ const (
|
||||
// imageRenditionCacheSubdir 存放挂载 Emby 直接按尺寸产出的成品图:它们
|
||||
// 已经是客户端要的最终尺寸,和派生缩略图一样属于长期保留的热路径缓存。
|
||||
imageRenditionCacheSubdir = "renditions"
|
||||
|
||||
// defaultResizeQueueWait 是请求为一格解码槽排队的最长时间。一张 3MP
|
||||
// 海报的解码+缩放约 0.15s,2 核机器两格槽位足以在几秒内消化一整屏
|
||||
// 海报;等待远好于「槽位忙就回退多兆字节原图」(那样每张图在外部网络
|
||||
// 下要十几秒)。超时后仍回退原图,保证请求不会被无限期挂住。
|
||||
defaultResizeQueueWait = 3 * time.Second
|
||||
)
|
||||
|
||||
// NewImageProxy is the constructor.
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -138,8 +139,8 @@ func fitSize(srcW, srcH, maxW, maxH int) (int, int) {
|
||||
|
||||
var errImageResizeTooLarge = errors.New("image source exceeds resize pixel budget")
|
||||
|
||||
// resizeImageData 按选项缩放图片并重新编码。返回 unchanged=true 表示原图
|
||||
// 本身已满足目标尺寸,调用方应直接输出原始字节(不重复编码、不损失质量)。
|
||||
// resizeImageData 按选项缩放图片并重新编码。返回 unchanged=true 表示源图
|
||||
// 已经是可直接下发的紧凑字节,调用方应直接输出原始字节(不重复编码)。
|
||||
func resizeImageData(data []byte, o imageResizeOptions) (out []byte, ctype string, unchanged bool, err error) {
|
||||
if len(data) == 0 || !o.active() {
|
||||
return data, detectContentType(data), true, nil
|
||||
@@ -151,12 +152,21 @@ func resizeImageData(data []byte, o imageResizeOptions) (out []byte, ctype strin
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 {
|
||||
return nil, "", false, errors.New("invalid image dimensions")
|
||||
}
|
||||
// 先用 DecodeConfig 判断是否需要解码整图:图已够小就零成本返回原字节。
|
||||
// 先用 DecodeConfig 判断是否需要缩放。尺寸已在目标框内时仍要重编码:
|
||||
// 刮削来源的海报常是 1000×1500、1MB 以上的 PNG,直接原样下发会让一处
|
||||
// 海报墙就从「几十 KB 缩略图」变成「几十 MB 原图」——外部网络下每张
|
||||
// 都要十几秒。只有本来就是紧凑 Web 格式(JPEG/WebP)且体积很小的源图
|
||||
// 才值得零成本直出。
|
||||
dstW, dstH := fitSize(cfg.Width, cfg.Height, o.MaxWidth, o.MaxHeight)
|
||||
if dstW == cfg.Width && dstH == cfg.Height {
|
||||
unchangedSize := dstW == cfg.Width && dstH == cfg.Height
|
||||
if unchangedSize && isCompactWebFormat(format) && len(data) <= compactImageSkipBytes {
|
||||
return data, detectContentType(data), true, nil
|
||||
}
|
||||
if int64(cfg.Width)*int64(cfg.Height) > imageResizeMaxSourcePixels {
|
||||
if unchangedSize {
|
||||
// 尺寸已满足但像素量超出解码预算:宁可直出原图,也不冒 OOM 风险。
|
||||
return data, detectContentType(data), true, nil
|
||||
}
|
||||
return nil, "", false, errImageResizeTooLarge
|
||||
}
|
||||
|
||||
@@ -165,7 +175,12 @@ func resizeImageData(data []byte, o imageResizeOptions) (out []byte, ctype strin
|
||||
return nil, "", false, err
|
||||
}
|
||||
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
|
||||
resizeScaled(dst, src, cfg.Width, cfg.Height)
|
||||
if unchangedSize {
|
||||
// 不缩放时省掉一次全图重采样,只做格式转换。
|
||||
draw.Draw(dst, dst.Bounds(), src, src.Bounds().Min, draw.Src)
|
||||
} else {
|
||||
resizeScaled(dst, src, cfg.Width, cfg.Height)
|
||||
}
|
||||
|
||||
// 只有可能带透明的源格式才需要逐像素确认,避免 JPEG 的无谓遍历。
|
||||
// 写实海报的 PNG 通常比等价 JPEG 大一个数量级,因此在确认不含透明
|
||||
@@ -231,9 +246,12 @@ func isOpaqueImage(img *image.RGBA) bool {
|
||||
// resizeSourceKey 生成源图身份键:只覆盖源文件身份(路径 + 大小 + mtime),不含
|
||||
// 目标尺寸。同一源图的所有尺寸档位共用这个前缀,才能互相列举与派生(小图直接
|
||||
// 从已缓存的大图缩小,而不是重新解码原图)。
|
||||
//
|
||||
// 前缀里的 v3 是缓存格式版本:v2 时期「尺寸已在目标框内」的 PNG 会原样直出、
|
||||
// 不写缓存,而现在同样条件会重编码成 JPEG,旧缓存与新结果语义不同,必须换键。
|
||||
func (o imageResizeOptions) resizeSourceKey(sourceID string, stat os.FileInfo) string {
|
||||
h := sha256.New()
|
||||
_, _ = fmt.Fprintf(h, "v2|%s", sourceID)
|
||||
_, _ = fmt.Fprintf(h, "v3|%s", sourceID)
|
||||
if stat != nil {
|
||||
_, _ = fmt.Fprintf(h, "|%d|%d", stat.Size(), stat.ModTime().UnixNano())
|
||||
}
|
||||
@@ -356,13 +374,53 @@ func (p *ImageProxy) tryAcquireResizeSlot() (func(), bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// acquireResizeSlotFor 对中等体积的图只尝试一次槽位。忙则放弃缩放,
|
||||
// 避免背景图墙在 2 核机器上排成数秒。超大原图仍等待,以免直出多兆字节。
|
||||
func (p *ImageProxy) resizeQueueTimeout() time.Duration {
|
||||
if p != nil && p.resizeQueueWait > 0 {
|
||||
return p.resizeQueueWait
|
||||
}
|
||||
return defaultResizeQueueWait
|
||||
}
|
||||
|
||||
// acquireResizeSlotFor 对紧凑小图只尝试一次槽位,其余情况最多排队
|
||||
// resizeQueueTimeout。
|
||||
//
|
||||
// 从前这里对 1.5MB 以下的图一律「槽位忙就跳过缩放」,结果是并发加载海报墙
|
||||
// 时几乎每张图都回退成 1MB 上下的 PNG 原图:一页要传几十 MB,外部网络下单张
|
||||
// 图就要二十多秒。缩略图本身只有几十 KB,排队几百毫秒明显更划算;只有真正
|
||||
// 大到会拖慢页面的原图才值得在超时后直出。
|
||||
func (p *ImageProxy) acquireResizeSlotFor(ctx context.Context, size int64) (func(), bool) {
|
||||
if size > 0 && size <= resizeQueueBypassBytes {
|
||||
if size > 0 && size <= compactImageSkipBytes {
|
||||
return p.tryAcquireResizeSlot()
|
||||
}
|
||||
return p.acquireResizeSlot(ctx)
|
||||
return p.acquireResizeSlotWithin(ctx, p.resizeQueueTimeout())
|
||||
}
|
||||
|
||||
// acquireResizeSlotWithin 在 timeout 内等待一格解码槽;超时或请求取消返回
|
||||
// false,调用方回退原图。
|
||||
func (p *ImageProxy) acquireResizeSlotWithin(ctx context.Context, timeout time.Duration) (func(), bool) {
|
||||
if p == nil {
|
||||
return func() {}, true
|
||||
}
|
||||
if timeout <= 0 {
|
||||
return p.tryAcquireResizeSlot()
|
||||
}
|
||||
p.resizeSemMu.Lock()
|
||||
if p.resizeSem == nil {
|
||||
p.resizeSem = make(chan struct{}, imageResizeConcurrency())
|
||||
}
|
||||
sem := p.resizeSem
|
||||
p.resizeSemMu.Unlock()
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
return func() { <-sem }, true
|
||||
case <-timer.C:
|
||||
return nil, false
|
||||
case <-ctx.Done():
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// serveResizedFromFile 从 srcPath 读取图片,按选项缩放后写出,并把结果缓存
|
||||
@@ -472,10 +530,6 @@ func (p *ImageProxy) ensureResizeCache(ctx context.Context, srcPath string, o im
|
||||
// ponytail: 200KB 覆盖这台机器上的典型海报(平均约 100KB);更大的图仍尝试缩放。
|
||||
const compactImageSkipBytes = 200 * 1024
|
||||
|
||||
// resizeQueueBypassBytes 是“槽位忙就放弃缩放”的上限。超过它的原图继续排队,
|
||||
// 以免把未压缩的剧照直接发给客户端。
|
||||
const resizeQueueBypassBytes = 1536 * 1024
|
||||
|
||||
// serveCompactOriginal 在源文件已经很小且是浏览器可直接显示的 JPEG/WebP 时
|
||||
// 跳过解码。返回 false 表示仍应走缩放路径。
|
||||
func serveCompactOriginal(w http.ResponseWriter, r *http.Request, srcPath string, stat os.FileInfo) bool {
|
||||
@@ -504,6 +558,17 @@ func isCompactWebImage(header []byte) bool {
|
||||
string(header[8:12]) == "WEBP"
|
||||
}
|
||||
|
||||
// isCompactWebFormat 判断 image.DecodeConfig 报告的格式是否已经适合直接下发
|
||||
// 给浏览器。PNG/BMP/GIF 即使尺寸很小也可能有几百 KB,需要重编码成 JPEG。
|
||||
func isCompactWebFormat(format string) bool {
|
||||
switch strings.ToLower(format) {
|
||||
case "jpeg", "jpg", "webp":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// writeResizeCache 原子写入缩放结果;失败只记日志,不影响本次响应。
|
||||
// 与 writeImageCache 一样不再持有全局锁:临时文件名唯一、rename 原子,
|
||||
// 持锁只会把缩略图的写盘和原图的写盘串成一条队。
|
||||
|
||||
@@ -123,17 +123,58 @@ func TestResizeImageDataScalesDownAndReencodesAsJPEG(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeImageDataLeavesImagesWithinBoundsUntouched(t *testing.T) {
|
||||
// 尺寸已经在目标框内的 PNG 也必须转成 JPEG。刮削来的海报常是 1000×1500、
|
||||
// 1MB 级的 PNG;此前这种情况原样直出,一处海报墙就要下载几十 MB,外部网络下
|
||||
// 每张图要十几秒(线上实测单张 1.3MB / 20 多秒)。
|
||||
func TestResizeImageDataReencodesPNGWithinBounds(t *testing.T) {
|
||||
data := encodeTestPNG(t, 200, 300, 255)
|
||||
out, _, unchanged, err := resizeImageData(data, imageResizeOptions{MaxWidth: 400, MaxHeight: 600})
|
||||
out, ctype, unchanged, err := resizeImageData(data, imageResizeOptions{MaxWidth: 400, MaxHeight: 600, Quality: 80})
|
||||
if err != nil {
|
||||
t.Fatalf("resize: %v", err)
|
||||
}
|
||||
if unchanged {
|
||||
t.Fatal("PNG within the target box must still be re-encoded as a compact web format")
|
||||
}
|
||||
if ctype != "image/jpeg" {
|
||||
t.Fatalf("content type = %q, want image/jpeg", ctype)
|
||||
}
|
||||
cfg, format, err := image.DecodeConfig(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatalf("decode re-encoded image: %v", err)
|
||||
}
|
||||
if format != "jpeg" {
|
||||
t.Fatalf("format = %q, want jpeg", format)
|
||||
}
|
||||
if cfg.Width != 200 || cfg.Height != 300 {
|
||||
t.Fatalf("re-encoded to %dx%d, want the original 200x300 (no upscaling)", cfg.Width, cfg.Height)
|
||||
}
|
||||
}
|
||||
|
||||
// 已经是紧凑 Web 格式且体积很小的源图仍然零成本直出。
|
||||
func TestResizeImageDataLeavesSmallCompactSourceUntouched(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
img := image.NewRGBA(image.Rect(0, 0, 200, 300))
|
||||
for y := 0; y < 300; y++ {
|
||||
for x := 0; x < 200; x++ {
|
||||
img.Set(x, y, color.RGBA{R: uint8(x % 16 * 16), G: uint8(y % 16 * 16), B: 200, A: 255})
|
||||
}
|
||||
}
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}); err != nil {
|
||||
t.Fatalf("encode test jpeg: %v", err)
|
||||
}
|
||||
data := buf.Bytes()
|
||||
if len(data) > compactImageSkipBytes {
|
||||
t.Fatalf("test jpeg is %d bytes, expected a compact source", len(data))
|
||||
}
|
||||
out, ctype, unchanged, err := resizeImageData(data, imageResizeOptions{MaxWidth: 400, MaxHeight: 600})
|
||||
if err != nil {
|
||||
t.Fatalf("resize: %v", err)
|
||||
}
|
||||
if !unchanged {
|
||||
t.Fatal("expected an image already within bounds to be returned unchanged")
|
||||
t.Fatal("a compact JPEG already within bounds should be served unchanged")
|
||||
}
|
||||
if !bytes.Equal(out, data) {
|
||||
t.Fatal("expected the original bytes to be returned verbatim")
|
||||
if !bytes.Equal(out, data) || ctype != "image/jpeg" {
|
||||
t.Fatalf("expected the original jpeg bytes back, got %d bytes of %q", len(out), ctype)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,7 +343,9 @@ func TestServeResizedFromFileSkipsDecodeForCompactJPEG(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeResizedFromFileDoesNotQueueWhenResizeBusy(t *testing.T) {
|
||||
// 排队上限:槽位一直不空时,请求最多等 resizeQueueWait,然后回退原图,
|
||||
// 不会被无限期挂住。这条约束保护的是请求本身,而不是「不等就直出原图」。
|
||||
func TestServeResizedFromFileFallsBackWhenResizeQueueStaysBusy(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := dir + string(os.PathSeparator) + "backdrop.jpg"
|
||||
data := make([]byte, compactImageSkipBytes+1024)
|
||||
@@ -310,7 +353,7 @@ func TestServeResizedFromFileDoesNotQueueWhenResizeBusy(t *testing.T) {
|
||||
if err := os.WriteFile(src, data, 0o644); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache"}
|
||||
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache", resizeQueueWait: 50 * time.Millisecond}
|
||||
var releases []func()
|
||||
for i := 0; i < imageResizeConcurrency(); i++ {
|
||||
release, ok := proxy.acquireResizeSlot(context.Background())
|
||||
@@ -327,10 +370,77 @@ func TestServeResizedFromFileDoesNotQueueWhenResizeBusy(t *testing.T) {
|
||||
|
||||
start := time.Now()
|
||||
handled := proxy.serveResizedFromFile(httptest.NewRecorder(), httptest.NewRequest("GET", "/x?maxWidth=480", nil), src, imageResizeOptions{MaxWidth: 480})
|
||||
if time.Since(start) > 200*time.Millisecond {
|
||||
t.Fatal("resize slot was busy but the request still waited in the queue")
|
||||
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
|
||||
t.Fatalf("request waited %v with every slot held; the queue wait must be bounded", elapsed)
|
||||
}
|
||||
if handled {
|
||||
t.Fatal("expected a busy resize slot to fall back so the caller can serve the original")
|
||||
t.Fatal("expected a permanently busy queue to fall back so the caller can serve the original")
|
||||
}
|
||||
}
|
||||
|
||||
// 槽位只是短暂忙碌时必须排队等待并产出缩略图,而不是直出多兆字节原图。
|
||||
// 这是线上「海报墙每张图 20 多秒」的根因:并发加载时槽位全忙,旧逻辑把
|
||||
// 1MB 级 PNG 原样下发。
|
||||
// encodeNoisyPNG 生成难以压缩的 PNG:规则色块会被压到 1KB 级别,测不出
|
||||
// 「大体积 PNG 被原样下发」这条回归。
|
||||
func encodeNoisyPNG(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
seed := uint32(0x9e3779b9)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
seed = seed*1664525 + 1013904223
|
||||
img.Set(x, y, color.RGBA{R: uint8(seed >> 24), G: uint8(seed >> 16), B: uint8(seed >> 8), A: 255})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("encode noisy png: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestServeResizedFromFileWaitsForSlotInsteadOfServingOriginal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := dir + string(os.PathSeparator) + "poster.png"
|
||||
original := encodeNoisyPNG(t, 378, 540)
|
||||
if len(original) <= compactImageSkipBytes {
|
||||
t.Fatalf("test poster is %d bytes, want a source above the compact threshold", len(original))
|
||||
}
|
||||
if err := os.WriteFile(src, original, 0o644); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache"}
|
||||
var releases []func()
|
||||
for i := 0; i < imageResizeConcurrency(); i++ {
|
||||
release, ok := proxy.acquireResizeSlot(context.Background())
|
||||
if !ok {
|
||||
t.Fatal("expected a resize slot")
|
||||
}
|
||||
releases = append(releases, release)
|
||||
}
|
||||
go func() {
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
releases[0]()
|
||||
}()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
if !proxy.serveResizedFromFile(rec, httptest.NewRequest("GET", "/x?maxWidth=480&maxHeight=600", nil), src, imageResizeOptions{MaxWidth: 480, MaxHeight: 600}) {
|
||||
t.Fatal("expected the request to wait for the slot and serve a thumbnail")
|
||||
}
|
||||
body := rec.Body.Bytes()
|
||||
if bytes.Equal(body, original) {
|
||||
t.Fatal("served the multi-hundred-KB PNG original instead of a re-encoded thumbnail")
|
||||
}
|
||||
if rec.Header().Get("Content-Type") != "image/jpeg" {
|
||||
t.Fatalf("content type = %q, want image/jpeg", rec.Header().Get("Content-Type"))
|
||||
}
|
||||
// 同一请求第二次必须直接命中缩放缓存。
|
||||
rec2 := httptest.NewRecorder()
|
||||
if !proxy.serveResizedFromFile(rec2, httptest.NewRequest("GET", "/x?maxWidth=480&maxHeight=600", nil), src, imageResizeOptions{MaxWidth: 480, MaxHeight: 600}) {
|
||||
t.Fatal("expected the cached thumbnail to be reused")
|
||||
}
|
||||
if !bytes.Equal(rec2.Body.Bytes(), body) {
|
||||
t.Fatal("expected the cached thumbnail bytes to be identical")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,14 @@ import (
|
||||
|
||||
// MediaService offers high-level CRUD over libraries and media items.
|
||||
type MediaService struct {
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
cache *RuntimeCacheService
|
||||
groupedMediaFlight singleflight.Group
|
||||
libraryRowsFlight singleflight.Group
|
||||
recentSeriesFlight singleflight.Group
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
cache *RuntimeCacheService
|
||||
groupedMediaFlight singleflight.Group
|
||||
mediaSortFlight singleflight.Group
|
||||
libraryRowsFlight singleflight.Group
|
||||
recentSeriesFlight singleflight.Group
|
||||
}
|
||||
|
||||
type MediaVisibility struct {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
@@ -129,6 +129,26 @@ func (s *MediaService) GroupedMediaVisibleFiltered(
|
||||
visibility MediaVisibility,
|
||||
filters MediaListFilters,
|
||||
) ([]MediaItem, error) {
|
||||
value, err := s.groupedMediaVisibleFiltered(ctx, libraryID, visibility, filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return value.Items, nil
|
||||
}
|
||||
|
||||
// Each immutable grouping has its own revision. Lightweight sort indices can
|
||||
// reference that revision without retaining another copy of the full rows.
|
||||
type groupedMediaCacheValue struct {
|
||||
Items []MediaItem
|
||||
Revision string
|
||||
}
|
||||
|
||||
func (s *MediaService) groupedMediaVisibleFiltered(
|
||||
ctx context.Context,
|
||||
libraryID string,
|
||||
visibility MediaVisibility,
|
||||
filters MediaListFilters,
|
||||
) (*groupedMediaCacheValue, error) {
|
||||
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
|
||||
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID)
|
||||
if err != nil {
|
||||
@@ -143,7 +163,7 @@ func (s *MediaService) GroupedMediaVisibleFiltered(
|
||||
value, err, _ := s.groupedMediaFlight.Do(itemsCacheKey, func() (any, error) {
|
||||
if s.cache != nil {
|
||||
if cachedObj, ok := s.cache.GetObject(itemsCacheKey); ok {
|
||||
if cached, ok := cachedObj.([]MediaItem); ok {
|
||||
if cached, ok := cachedObj.(*groupedMediaCacheValue); ok {
|
||||
return cached, nil
|
||||
}
|
||||
}
|
||||
@@ -154,8 +174,8 @@ func (s *MediaService) GroupedMediaVisibleFiltered(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
grouped := groupMediaVersions(items)
|
||||
if s.cache != nil && len(grouped) > 0 {
|
||||
grouped := &groupedMediaCacheValue{Items: groupMediaVersions(items), Revision: uuid.NewString()}
|
||||
if s.cache != nil && len(grouped.Items) > 0 {
|
||||
s.cache.SetObject(itemsCacheKey, grouped, s.mediaObjectTTL())
|
||||
}
|
||||
return grouped, nil
|
||||
@@ -163,10 +183,10 @@ func (s *MediaService) GroupedMediaVisibleFiltered(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if grouped, ok := value.([]MediaItem); ok {
|
||||
if grouped, ok := value.(*groupedMediaCacheValue); ok {
|
||||
return grouped, nil
|
||||
}
|
||||
return nil, nil
|
||||
return &groupedMediaCacheValue{}, nil
|
||||
}
|
||||
|
||||
func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryID string, visibility MediaVisibility, filters MediaListFilters) ([]model.Media, error) {
|
||||
@@ -180,24 +200,19 @@ func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryI
|
||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||
})
|
||||
// 版本分组的 URL 分页发生在 Go 进程内,响应里的 total 是分组后的数量,
|
||||
// 不需要数据库再为原始行做一次 COUNT(*)。全量 COUNT 在超大媒体库上
|
||||
// 会重复扫描整个 library_id 范围,而这里只关心是否存在截断风险。
|
||||
items, err := s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, maxMediaSearchLimit, filter)
|
||||
// Group before paginating so versions stay together. The search safety limit
|
||||
// must not truncate a library listing or its reported grouped total.
|
||||
items, err := s.repo.Media.ListAllByLibrariesFilteredNoCount(ctx, libraryIDs, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(items) >= maxMediaSearchLimit && s.log != nil {
|
||||
s.log.Warn("media version grouping may be truncated by safety limit",
|
||||
zap.String("library_id", libraryID),
|
||||
zap.Int("limit", maxMediaSearchLimit))
|
||||
}
|
||||
s.attachLibraryMetadata(ctx, items)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetMedia returns a single media row.
|
||||
func (s *MediaService) GetMedia(ctx context.Context, id string) (*model.Media, error) { media, err := s.repo.Media.FindByID(ctx, id)
|
||||
func (s *MediaService) GetMedia(ctx context.Context, id string) (*model.Media, error) {
|
||||
media, err := s.repo.Media.FindByID(ctx, id)
|
||||
if err != nil || media == nil {
|
||||
return media, err
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ListMediaVisibleSortedPage reuses grouped rows and a small position index.
|
||||
// Only the requested page is copied; pagination does not sort the full library
|
||||
// again or retain a second full-size MediaItem slice for every sort order.
|
||||
func (s *MediaService) ListMediaVisibleSortedPage(
|
||||
ctx context.Context,
|
||||
libraryID string,
|
||||
page, pageSize int,
|
||||
visibility MediaVisibility,
|
||||
filters MediaListFilters,
|
||||
spec MediaSortSpec,
|
||||
history map[string]time.Time,
|
||||
) ([]MediaItem, int64, error) {
|
||||
grouped, err := s.groupedMediaVisibleFiltered(ctx, libraryID, visibility, filters)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
spec = NormalizeMediaSort(spec.Field, spec.Order)
|
||||
indices := s.sortedMediaIndices(grouped, spec, history)
|
||||
page, pageSize = normalizeGroupedMediaPage(page, pageSize)
|
||||
total := len(grouped.Items)
|
||||
// Division avoids overflowing (page-1)*pageSize on an excessive page value.
|
||||
if total == 0 || page-1 > (total-1)/pageSize {
|
||||
return []MediaItem{}, int64(total), nil
|
||||
}
|
||||
start := (page - 1) * pageSize
|
||||
end := min(start+pageSize, total)
|
||||
items := make([]MediaItem, 0, end-start)
|
||||
for _, index := range indices[start:end] {
|
||||
items = append(items, grouped.Items[index])
|
||||
}
|
||||
return items, int64(total), nil
|
||||
}
|
||||
|
||||
func (s *MediaService) sortedMediaIndices(grouped *groupedMediaCacheValue, spec MediaSortSpec, history map[string]time.Time) []int {
|
||||
// Playback history changes independently of the media cache and is personal.
|
||||
// Keep this explicit sort live rather than caching another user's history.
|
||||
if s.cache == nil || spec.Field == "last_played" || len(grouped.Items) == 0 {
|
||||
return sortMediaIndices(grouped.Items, spec, history)
|
||||
}
|
||||
key := fmt.Sprintf("media:obj:sort:%s:%s:%s", grouped.Revision, spec.Field, spec.Order)
|
||||
value, _, _ := s.mediaSortFlight.Do(key, func() (any, error) {
|
||||
if cached, ok := s.cache.GetObject(key); ok {
|
||||
if indices, ok := cached.([]int); ok {
|
||||
return indices, nil
|
||||
}
|
||||
}
|
||||
indices := sortMediaIndices(grouped.Items, spec, history)
|
||||
s.cache.SetObject(key, indices, s.mediaObjectTTL())
|
||||
return indices, nil
|
||||
})
|
||||
return value.([]int)
|
||||
}
|
||||
|
||||
func sortMediaIndices(items []MediaItem, spec MediaSortSpec, history map[string]time.Time) []int {
|
||||
indices := make([]int, len(items))
|
||||
for i := range indices {
|
||||
indices[i] = i
|
||||
}
|
||||
sort.SliceStable(indices, func(i, j int) bool {
|
||||
a, b := items[indices[i]], items[indices[j]]
|
||||
cmp := compareMediaByField(a, b, spec, history)
|
||||
if cmp == 0 {
|
||||
cmp = compareID(a.ID, b.ID)
|
||||
}
|
||||
return cmp < 0
|
||||
})
|
||||
return indices
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func TestSortedMediaPageIncludesRowsBeyondSearchLimit(t *testing.T) {
|
||||
db := newServiceTestDB(t)
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "Large", Path: "/media/large", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// This must cross the historical 50,000-row boundary: a smaller fixture
|
||||
// cannot detect truncation before grouping, counting, and sorting.
|
||||
now := time.Now()
|
||||
for start := 0; start <= maxMediaSearchLimit; start += 500 {
|
||||
var rows []model.Media
|
||||
for i := start; i < min(start+500, maxMediaSearchLimit+1); i++ {
|
||||
rows = append(rows, model.Media{
|
||||
Base: model.Base{ID: fmt.Sprintf("large-%05d", i), CreatedAt: now, UpdatedAt: now},
|
||||
LibraryID: lib.ID, Path: fmt.Sprintf("/media/large/%05d.mkv", i),
|
||||
Title: fmt.Sprintf("Movie %05d", i), TMDbID: i + 1,
|
||||
})
|
||||
}
|
||||
if err := db.CreateInBatches(&rows, 100).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
||||
items, total, err := svc.ListMediaVisibleSortedPage(t.Context(), lib.ID, maxMediaSearchLimit/100+1, 100,
|
||||
MediaVisibility{IncludeNSFW: true}, MediaListFilters{}, MediaSortSpec{Field: "title", Order: "asc"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != int64(maxMediaSearchLimit+1) || len(items) != 1 || items[0].ID != "large-50000" {
|
||||
t.Fatalf("total=%d, page length=%d, want 50001 rows and final item large-50000", total, len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortedMediaPagesReuseIndicesAndRefreshAfterInvalidation(t *testing.T) {
|
||||
db := newServiceTestDB(t)
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := []model.Media{
|
||||
{Base: model.Base{ID: "alpha"}, LibraryID: lib.ID, Path: "/media/movies/a.mkv", Title: "Alpha", TMDbID: 1, SizeBytes: 100},
|
||||
{Base: model.Base{ID: "alpha-alt"}, LibraryID: lib.ID, Path: "/media/movies/a-alt.mkv", Title: "Alpha", TMDbID: 1, SizeBytes: 50},
|
||||
{Base: model.Base{ID: "beta"}, LibraryID: lib.ID, Path: "/media/movies/b.mkv", Title: "Beta", TMDbID: 2},
|
||||
{Base: model.Base{ID: "hidden"}, LibraryID: lib.ID, Path: "/media/movies/hidden.mkv", Title: "Hidden", TMDbID: 3, NSFW: true},
|
||||
}
|
||||
if err := db.Create(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
|
||||
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
|
||||
visibility := MediaVisibility{IncludeNSFW: false}
|
||||
spec := MediaSortSpec{Field: "title", Order: "asc"}
|
||||
first, total, err := svc.ListMediaVisibleSortedPage(t.Context(), lib.ID, 1, 1, visibility, MediaListFilters{}, spec, nil)
|
||||
if err != nil || total != 2 || len(first) != 1 || first[0].ID != "alpha" || len(first[0].Versions) != 2 {
|
||||
t.Fatalf("first=%#v, total=%d, error=%v", first, total, err)
|
||||
}
|
||||
grouped, err := svc.groupedMediaVisibleFiltered(t.Context(), lib.ID, visibility, MediaListFilters{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
index := svc.sortedMediaIndices(grouped, spec, nil)
|
||||
second, _, err := svc.ListMediaVisibleSortedPage(t.Context(), lib.ID, 2, 1, visibility, MediaListFilters{}, spec, nil)
|
||||
if err != nil || len(second) != 1 || second[0].ID != "beta" {
|
||||
t.Fatalf("second=%#v, error=%v", second, err)
|
||||
}
|
||||
cachedIndex := svc.sortedMediaIndices(grouped, spec, nil)
|
||||
if &index[0] != &cachedIndex[0] {
|
||||
t.Fatal("pagination rebuilt the sorted position index")
|
||||
}
|
||||
// The returned page is independently writable without corrupting cached rows.
|
||||
first[0].Title = "Modified by caller"
|
||||
if err := db.Model(&model.Media{}).Where("id = ?", "beta").Update("title", "Aardvark").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc.invalidateMediaCache(t.Context())
|
||||
fresh, _, err := svc.ListMediaVisibleSortedPage(t.Context(), lib.ID, 1, 1, visibility, MediaListFilters{}, spec, nil)
|
||||
if err != nil || len(fresh) != 1 || fresh[0].ID != "beta" {
|
||||
t.Fatalf("updated page=%#v, error=%v", fresh, err)
|
||||
}
|
||||
empty, _, err := svc.ListMediaVisibleSortedPage(t.Context(), lib.ID, math.MaxInt, 100, visibility, MediaListFilters{}, spec, nil)
|
||||
if err != nil || empty == nil || len(empty) != 0 {
|
||||
t.Fatalf("out-of-range page=%#v, error=%v", empty, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaSortIndexDoesNotCachePersonalPlaybackHistory(t *testing.T) {
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), nil).
|
||||
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
|
||||
grouped := &groupedMediaCacheValue{Revision: "history-test", Items: []MediaItem{
|
||||
{Media: model.Media{Base: model.Base{ID: "a"}}},
|
||||
{Media: model.Media{Base: model.Base{ID: "b"}}},
|
||||
}}
|
||||
spec := MediaSortSpec{Field: "last_played", Order: "desc"}
|
||||
first := svc.sortedMediaIndices(grouped, spec, map[string]time.Time{"a": time.Now()})
|
||||
second := svc.sortedMediaIndices(grouped, spec, map[string]time.Time{"b": time.Now()})
|
||||
if first[0] != 0 || second[0] != 1 {
|
||||
t.Fatalf("history sorts = %v, %v", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMediaSortIndex(b *testing.B) {
|
||||
items := make([]MediaItem, 10000)
|
||||
for i := range items {
|
||||
items[i].ID = fmt.Sprint(i)
|
||||
items[i].Title = fmt.Sprintf("Movie %05d", len(items)-i)
|
||||
}
|
||||
spec := MediaSortSpec{Field: "title", Order: "asc"}
|
||||
b.Run("sort-every-page", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
SortMediaItems(items, spec.Field, spec.Order, nil)
|
||||
}
|
||||
})
|
||||
b.Run("cached-position-index", func(b *testing.B) {
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), nil).
|
||||
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
|
||||
grouped := &groupedMediaCacheValue{Items: items, Revision: "benchmark"}
|
||||
svc.sortedMediaIndices(grouped, spec, nil)
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
svc.sortedMediaIndices(grouped, spec, nil)
|
||||
}
|
||||
})
|
||||
}
|
||||
+29
-26
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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 增量入库,无需周期性全量重扫。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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 状态)
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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 },
|
||||
]
|
||||
|
||||
@@ -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 ${
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { Fragment, useEffect, type ReactNode } from 'react'
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { usePlayProfileStore } from '../stores/playProfile'
|
||||
import { libraryCacheScope } from '../utils/libraryCache'
|
||||
import { hydrateReaderSettings, startReaderSettingsSync } from '../utils/readerSettingsSync'
|
||||
|
||||
// Route guard: redirects to /login when no token is present.
|
||||
export function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token)
|
||||
const userId = useAuthStore((s) => s.user?.id ?? null)
|
||||
// Drop local page state and in-flight callbacks when visibility changes.
|
||||
useAuthStore((s) => s.user)
|
||||
usePlayProfileStore((s) => s.activeProfileId)
|
||||
usePlayProfileStore((s) => s.activeProfilePinToken)
|
||||
const visibilityKey = libraryCacheScope()
|
||||
const location = useLocation()
|
||||
|
||||
// 阅读器偏好按账号存在服务端:一进入受保护区域就开启回写订阅,并在
|
||||
@@ -23,7 +30,7 @@ export function RequireAuth({ children }: { children: ReactNode }) {
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace state={{ from: location }} />
|
||||
}
|
||||
return <>{children}</>
|
||||
return <Fragment key={visibilityKey}>{children}</Fragment>
|
||||
}
|
||||
|
||||
// Route guard: only allows users with role === "admin".
|
||||
|
||||
@@ -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'
|
||||
) {
|
||||
|
||||
@@ -15,6 +15,7 @@ const SLOW_RECONNECT_INTERVAL = 60_000
|
||||
export function useWebSocket(onEvent: (topic: string, payload: unknown) => void) {
|
||||
const ref = useRef<WebSocket | null>(null)
|
||||
const token = useAuthStore((s) => s.token)
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === 'admin')
|
||||
const onEventRef = useRef(onEvent)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -22,7 +23,7 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
|
||||
}, [onEvent])
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
if (!token || !isAdmin) return
|
||||
let closed = false
|
||||
let timer: number | undefined
|
||||
let reconnectAttempts = 0
|
||||
@@ -88,5 +89,5 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
|
||||
if (timer) window.clearTimeout(timer)
|
||||
ref.current?.close()
|
||||
}
|
||||
}, [token])
|
||||
}, [token, isAdmin])
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }} />
|
||||
|
||||
@@ -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'
|
||||
@@ -36,7 +37,7 @@ import {
|
||||
type SubtitlePosition,
|
||||
type SubtitleStylePreset,
|
||||
} from '../utils/subtitleDisplay'
|
||||
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, isStrmMedia, type PlayerMode } from './playerPageModel'
|
||||
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, isStrmMedia, shouldShowPlayerDiagnostics, type PlayerMode } from './playerPageModel'
|
||||
import { classifyDirectPlayError, directPlayFailureCopy, directStallTimeoutMs, DIRECT_SEEK_GRACE_MS } from './directPlayError'
|
||||
import type { DirectPlayFailureReason } from './directPlayError'
|
||||
import { canSeekDirectNow, DIRECT_SEEK_WAIT_MS } from './directPlaySeek'
|
||||
@@ -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)
|
||||
@@ -174,6 +181,7 @@ export function PlayerPage() {
|
||||
const [playerError, setPlayerError] = useState('')
|
||||
// 媒体元数据加载失败(404 / 无权限等):舞台区直接展示错误而不是永远「加载中」
|
||||
const [loadError, setLoadError] = useState('')
|
||||
const diagnosticsVisible = shouldShowPlayerDiagnostics(playerError, loadError)
|
||||
// 「客户端直连解码」模式:宿主机不转码,播放器强制 direct play、隐藏 HLS 切换。
|
||||
const [directOnly, setDirectOnly] = useState(false)
|
||||
const [directOnlyKnown, setDirectOnlyKnown] = useState(false)
|
||||
@@ -498,6 +506,8 @@ export function PlayerPage() {
|
||||
// 切换视频时重置媒体与弹幕状态,确保新视频自动重新识别并加载弹幕
|
||||
useEffect(() => {
|
||||
setMedia(null)
|
||||
setDiagnosticEvents([])
|
||||
setDiagnosticsOpen(false)
|
||||
setLoadError('')
|
||||
vr360TouchedRef.current = false
|
||||
vr360TextureRetryUsedRef.current = false
|
||||
@@ -556,6 +566,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 +606,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 +1237,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 +1273,7 @@ export function PlayerPage() {
|
||||
directOnly,
|
||||
hlsUnavailable,
|
||||
playbackInfo,
|
||||
recordDiagnostic,
|
||||
selectedQuality,
|
||||
setPlaybackMode,
|
||||
showPlayFailure,
|
||||
@@ -2381,6 +2421,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 +2609,37 @@ export function PlayerPage() {
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{diagnosticsVisible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDiagnosticsOpen((open) => !open)}
|
||||
className="absolute right-2.5 top-14 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 sm:right-3.5 sm:top-16"
|
||||
title="播放诊断"
|
||||
aria-label="播放诊断"
|
||||
>
|
||||
<Activity size={15} />诊断
|
||||
</button>
|
||||
)}
|
||||
{diagnosticsVisible && diagnosticsOpen && (
|
||||
<aside className="absolute right-2.5 top-[6.75rem] z-[80] flex max-h-[calc(100%-8.5rem)] w-[min(92vw,420px)] flex-col overflow-hidden rounded-2xl border border-white/15 bg-[#111827]/95 text-white shadow-2xl backdrop-blur sm:right-3.5 sm:top-[7.25rem]">
|
||||
<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="min-h-0 flex-1 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -35,27 +36,34 @@ export function SettingsPage() {
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
// refresh 只等真正需要的设置项返回。媒体库列表仅供「成人库」等下拉选项
|
||||
// 使用,晚到不影响设置项渲染;此前用 Promise.all 一并等待,远程 Emby
|
||||
// 挂载枚举偶尔要 10–17 秒,整页就一直空转。
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [all, libs] = await Promise.all([
|
||||
adminAPI.listSettings(),
|
||||
libraryAPI.list({ includeHidden: true }).catch(() => [] as Library[]),
|
||||
])
|
||||
const all = await adminAPI.listSettings()
|
||||
const idx: Record<string, string> = {}
|
||||
for (const s of all as Setting[]) {
|
||||
if (ALL_KEYS.has(s.key)) idx[s.key] = s.value
|
||||
}
|
||||
setValues(idx)
|
||||
setLibraries(libs as Library[])
|
||||
setDirty(new Set())
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshLibraries = () => {
|
||||
libraryAPI
|
||||
.list({ includeHidden: true })
|
||||
.then((libs) => setLibraries(libs as Library[]))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
refreshLibraries()
|
||||
}, [])
|
||||
|
||||
const onChange = (key: string, value: string) => {
|
||||
@@ -172,6 +180,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 />}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { shouldShowPlayerDiagnostics } from './playerPageModel.ts'
|
||||
|
||||
function check(name: string, actual: boolean, expected: boolean) {
|
||||
if (actual !== expected) throw new Error(`playerDiagnostics: ${name}`)
|
||||
}
|
||||
|
||||
check('hidden by default and during normal playback', shouldShowPlayerDiagnostics('', ''), false)
|
||||
check('visible when playback fails', shouldShowPlayerDiagnostics('视频播放失败', ''), true)
|
||||
check('visible when media cannot be loaded', shouldShowPlayerDiagnostics('', '无法加载该媒体'), true)
|
||||
check('visible when both loading and playback fail', shouldShowPlayerDiagnostics('视频播放失败', '无法加载该媒体'), true)
|
||||
check('hidden again after errors are cleared', shouldShowPlayerDiagnostics('', ''), false)
|
||||
|
||||
console.log('playerDiagnostics.test.ts ok')
|
||||
@@ -3,6 +3,10 @@ import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||
|
||||
export type PlayerMode = 'direct' | 'hls'
|
||||
|
||||
export function shouldShowPlayerDiagnostics(playerError: string, loadError: string): boolean {
|
||||
return Boolean(playerError || loadError)
|
||||
}
|
||||
|
||||
const directContainers = ['mp4', 'webm', 'm4v']
|
||||
const directVideoCodecs = ['h264', 'avc', 'avc1']
|
||||
const directAudioCodecs = ['aac', 'mp3', 'opus']
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -158,11 +158,9 @@ export function useLibraryData(
|
||||
try {
|
||||
const resolved = await resolveLibrary(libraryID)
|
||||
if (cancelled || seq !== requestSeqRef.current) return
|
||||
if (!lib) {
|
||||
lib = resolved
|
||||
libraryRef.current = lib
|
||||
setLibrary(lib)
|
||||
}
|
||||
lib = resolved
|
||||
libraryRef.current = lib
|
||||
setLibrary(lib)
|
||||
} catch {
|
||||
if (!cancelled && seq === requestSeqRef.current) {
|
||||
setLoading(false)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,89 +1,35 @@
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Library } from '../types'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { usePlayProfileStore } from '../stores/playProfile'
|
||||
import { createLibraryCache } from './libraryCacheState'
|
||||
|
||||
// 会话级媒体库元数据缓存(不落 localStorage)。
|
||||
// 首页 / 媒体库页 / 媒体库详情页都要先拿库列表或库信息才能渲染,
|
||||
// 同一会话内反复进出这些页面时直接命中缓存,避免每次都等一整轮请求。
|
||||
// TTL 内直接返回缓存;过期后重新请求并刷新。并发调用共享同一个在途请求。
|
||||
|
||||
const LIST_TTL_MS = 30_000
|
||||
const LIBRARY_TTL_MS = 30_000
|
||||
|
||||
let listData: Library[] | null = null
|
||||
let listAt = 0
|
||||
let listInflight: Promise<Library[]> | null = null
|
||||
|
||||
const byId = new Map<string, { at: number; data: Library }>()
|
||||
const byIdInflight = new Map<string, Promise<Library>>()
|
||||
|
||||
function fresh(at: number, ttl: number): boolean {
|
||||
return Date.now() - at < ttl
|
||||
export function libraryCacheScope(): string {
|
||||
const { token, user } = useAuthStore.getState()
|
||||
const profile = usePlayProfileStore.getState()
|
||||
return JSON.stringify([
|
||||
Boolean(token),
|
||||
user?.id,
|
||||
user?.role,
|
||||
user?.hide_adult,
|
||||
user?.allowed_library_ids,
|
||||
profile.activeProfileId,
|
||||
profile.activeProfilePinToken,
|
||||
])
|
||||
}
|
||||
|
||||
function rememberList(data: Library[]) {
|
||||
listData = data
|
||||
listAt = Date.now()
|
||||
for (const lib of data) {
|
||||
byId.set(lib.id, { at: listAt, data: lib })
|
||||
}
|
||||
}
|
||||
const cache = createLibraryCache({
|
||||
list: () => libraryAPI.list(),
|
||||
get: (id) => libraryAPI.get(id),
|
||||
readScope: libraryCacheScope,
|
||||
})
|
||||
|
||||
// fetchLibraries 拉取库列表:TTL 内命中缓存立即返回(含 peek 先行渲染),
|
||||
// 过期或未缓存时发起请求,并发调用共享在途请求。
|
||||
export function fetchLibraries(): Promise<Library[]> {
|
||||
if (listData && fresh(listAt, LIST_TTL_MS)) return Promise.resolve(listData)
|
||||
if (!listInflight) {
|
||||
listInflight = libraryAPI
|
||||
.list()
|
||||
.then((rows) => {
|
||||
const data = Array.isArray(rows) ? rows : []
|
||||
rememberList(data)
|
||||
return data
|
||||
})
|
||||
.finally(() => {
|
||||
listInflight = null
|
||||
})
|
||||
}
|
||||
return listInflight
|
||||
}
|
||||
// Invalidate synchronously, including logout/login between two reads and auth
|
||||
// rehydration from another tab. Refreshing a token alone preserves the cache.
|
||||
useAuthStore.subscribe(() => cache.syncScope())
|
||||
usePlayProfileStore.subscribe(() => cache.syncScope())
|
||||
|
||||
// peekLibraries 同步返回当前缓存的库列表(可能过期),没有则 null。
|
||||
// 页面用它先行渲染上一次的数据,再由 fetchLibraries 在后台补一次刷新。
|
||||
export function peekLibraries(): Library[] | null {
|
||||
return listData
|
||||
}
|
||||
|
||||
// peekLibrary 同步返回单库缓存(库列表缓存也会回填单库索引)。
|
||||
export function peekLibrary(id: string): Library | null {
|
||||
const entry = byId.get(id)
|
||||
return entry?.data ?? null
|
||||
}
|
||||
|
||||
// resolveLibrary 拿单个库信息:命中缓存直接返回;未命中走 /libraries/:id
|
||||
// 并回填缓存。远程 Emby 挂载库的详情接口同样返回 is_remote_emby,缓存可用。
|
||||
export function resolveLibrary(id: string): Promise<Library> {
|
||||
const entry = byId.get(id)
|
||||
if (entry && fresh(entry.at, LIBRARY_TTL_MS)) return Promise.resolve(entry.data)
|
||||
const inflight = byIdInflight.get(id)
|
||||
if (inflight) return inflight
|
||||
const request = libraryAPI
|
||||
.get(id)
|
||||
.then((lib) => {
|
||||
byId.set(id, { at: Date.now(), data: lib })
|
||||
return lib
|
||||
})
|
||||
.finally(() => {
|
||||
byIdInflight.delete(id)
|
||||
})
|
||||
byIdInflight.set(id, request)
|
||||
return request
|
||||
}
|
||||
|
||||
// invalidate 清空缓存。管理页对库做过增删改后调用,确保下次拉到新数据。
|
||||
export function invalidateLibraries() {
|
||||
listData = null
|
||||
listAt = 0
|
||||
listInflight = null
|
||||
byId.clear()
|
||||
byIdInflight.clear()
|
||||
}
|
||||
export const fetchLibraries = cache.fetchLibraries
|
||||
export const peekLibraries = cache.peekLibraries
|
||||
export const peekLibrary = cache.peekLibrary
|
||||
export const resolveLibrary = cache.resolveLibrary
|
||||
export const invalidateLibraries = cache.invalidate
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { createLibraryCache } from './libraryCacheState.ts'
|
||||
import type { Library } from '../types/library.ts'
|
||||
|
||||
const library = (id: string) => ({ id, name: id } as Library)
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((done) => { resolve = done })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
test('list requests share a flight and populate the detail cache', async () => {
|
||||
const request = deferred<Library[]>()
|
||||
let calls = 0
|
||||
const cache = createLibraryCache({
|
||||
readScope: () => 'account-a:profile-a',
|
||||
list: () => { calls++; return request.promise },
|
||||
get: async () => { throw new Error('detail should be cached') },
|
||||
})
|
||||
const first = cache.fetchLibraries()
|
||||
assert.equal(first, cache.fetchLibraries())
|
||||
request.resolve([library('a')])
|
||||
await first
|
||||
assert.equal((await cache.resolveLibrary('a')).id, 'a')
|
||||
assert.equal((await cache.fetchLibraries())[0].id, 'a')
|
||||
assert.equal(calls, 1)
|
||||
})
|
||||
|
||||
test('account and profile changes discard both list and detail data', async () => {
|
||||
let scope = 'account-a:profile-a'
|
||||
const cache = createLibraryCache({
|
||||
readScope: () => scope,
|
||||
list: async () => [library(scope)],
|
||||
get: async () => library(scope),
|
||||
})
|
||||
await cache.fetchLibraries()
|
||||
scope = 'account-b:profile-a'
|
||||
assert.equal(cache.peekLibraries(), null)
|
||||
assert.equal(cache.peekLibrary('account-a:profile-a'), null)
|
||||
assert.equal((await cache.fetchLibraries())[0].id, scope)
|
||||
scope = 'account-b:profile-b'
|
||||
assert.equal(cache.peekLibraries(), null)
|
||||
assert.equal((await cache.resolveLibrary('shared-id')).id, scope)
|
||||
})
|
||||
|
||||
test('invalidation rejects old list results without clearing the new flight', async () => {
|
||||
const old = deferred<Library[]>()
|
||||
const fresh = deferred<Library[]>()
|
||||
let calls = 0
|
||||
const cache = createLibraryCache({
|
||||
readScope: () => 'a',
|
||||
list: () => ++calls === 1 ? old.promise : fresh.promise,
|
||||
get: async (id) => library(id),
|
||||
})
|
||||
const oldRequest = cache.fetchLibraries()
|
||||
const rejected = assert.rejects(oldRequest, { name: 'AbortError' })
|
||||
cache.invalidate()
|
||||
const current = cache.fetchLibraries()
|
||||
old.resolve([library('deleted')])
|
||||
await rejected
|
||||
assert.equal(cache.peekLibraries(), null)
|
||||
assert.equal(cache.fetchLibraries(), current)
|
||||
fresh.resolve([library('current')])
|
||||
await current
|
||||
assert.equal(cache.peekLibraries()?.[0].id, 'current')
|
||||
assert.equal(calls, 2)
|
||||
})
|
||||
|
||||
test('invalidation rejects old detail results without clearing the new flight', async () => {
|
||||
const old = deferred<Library>()
|
||||
const fresh = deferred<Library>()
|
||||
let calls = 0
|
||||
const cache = createLibraryCache({
|
||||
readScope: () => 'a',
|
||||
list: async () => [],
|
||||
get: () => ++calls === 1 ? old.promise : fresh.promise,
|
||||
})
|
||||
const oldRequest = cache.resolveLibrary('same-id')
|
||||
const rejected = assert.rejects(oldRequest, { name: 'AbortError' })
|
||||
cache.invalidate()
|
||||
const current = cache.resolveLibrary('same-id')
|
||||
old.resolve(library('old'))
|
||||
await rejected
|
||||
assert.equal(cache.peekLibrary('same-id'), null)
|
||||
assert.equal(cache.resolveLibrary('same-id'), current)
|
||||
fresh.resolve(library('current'))
|
||||
await current
|
||||
assert.equal(cache.peekLibrary('same-id')?.id, 'current')
|
||||
})
|
||||
|
||||
test('logout followed by login to the same account invalidates old requests', async () => {
|
||||
let scope = 'account-a'
|
||||
const request = deferred<Library[]>()
|
||||
const cache = createLibraryCache({ readScope: () => scope, list: () => request.promise, get: async libraryID => library(libraryID) })
|
||||
const pending = cache.fetchLibraries()
|
||||
const rejected = assert.rejects(pending, { name: 'AbortError' })
|
||||
scope = 'logged-out'
|
||||
cache.syncScope()
|
||||
scope = 'account-a'
|
||||
cache.syncScope()
|
||||
request.resolve([library('old-session')])
|
||||
await rejected
|
||||
assert.equal(cache.peekLibraries(), null)
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Library } from '../types/library.ts'
|
||||
|
||||
const TTL_MS = 30_000
|
||||
|
||||
// Kept independent of the API and auth stores so races can be tested with
|
||||
// deferred requests. Only the active visibility scope is retained in memory.
|
||||
export function createLibraryCache({
|
||||
list,
|
||||
get,
|
||||
readScope,
|
||||
}: {
|
||||
list: () => Promise<Library[]>
|
||||
get: (id: string) => Promise<Library>
|
||||
readScope: () => string
|
||||
}) {
|
||||
let scope = readScope()
|
||||
let generation = 0
|
||||
let listData: Library[] | null = null
|
||||
let listAt = 0
|
||||
let listInflight: Promise<Library[]> | null = null
|
||||
const byId = new Map<string, { at: number; data: Library }>()
|
||||
const byIdInflight = new Map<string, Promise<Library>>()
|
||||
|
||||
function invalidate() {
|
||||
generation += 1
|
||||
listData = null
|
||||
listAt = 0
|
||||
listInflight = null
|
||||
byId.clear()
|
||||
byIdInflight.clear()
|
||||
}
|
||||
|
||||
function syncScope() {
|
||||
const next = readScope()
|
||||
if (scope !== next) {
|
||||
scope = next
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
function checkGeneration(expected: number) {
|
||||
syncScope()
|
||||
if (generation !== expected) {
|
||||
const error = new Error('Library request belongs to an invalidated cache')
|
||||
error.name = 'AbortError'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function fetchLibraries(): Promise<Library[]> {
|
||||
syncScope()
|
||||
if (listData && Date.now() - listAt < TTL_MS) return Promise.resolve(listData)
|
||||
if (listInflight) return listInflight
|
||||
const expected = generation
|
||||
const request = list()
|
||||
.then((rows) => {
|
||||
checkGeneration(expected)
|
||||
const data = Array.isArray(rows) ? rows : []
|
||||
listData = data
|
||||
listAt = Date.now()
|
||||
byId.clear()
|
||||
for (const lib of data) byId.set(lib.id, { at: listAt, data: lib })
|
||||
return data
|
||||
})
|
||||
.finally(() => {
|
||||
if (listInflight === request) listInflight = null
|
||||
})
|
||||
listInflight = request
|
||||
return request
|
||||
}
|
||||
|
||||
function resolveLibrary(id: string): Promise<Library> {
|
||||
syncScope()
|
||||
const entry = byId.get(id)
|
||||
if (entry && Date.now() - entry.at < TTL_MS) return Promise.resolve(entry.data)
|
||||
const inflight = byIdInflight.get(id)
|
||||
if (inflight) return inflight
|
||||
const expected = generation
|
||||
const request = get(id)
|
||||
.then((lib) => {
|
||||
checkGeneration(expected)
|
||||
byId.set(id, { at: Date.now(), data: lib })
|
||||
return lib
|
||||
})
|
||||
.finally(() => {
|
||||
if (byIdInflight.get(id) === request) byIdInflight.delete(id)
|
||||
})
|
||||
byIdInflight.set(id, request)
|
||||
return request
|
||||
}
|
||||
|
||||
return {
|
||||
fetchLibraries,
|
||||
resolveLibrary,
|
||||
invalidate,
|
||||
syncScope,
|
||||
peekLibraries: () => {
|
||||
syncScope()
|
||||
return listData
|
||||
},
|
||||
peekLibrary: (id: string) => {
|
||||
syncScope()
|
||||
return byId.get(id)?.data ?? null
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user