Compare commits

...

12 Commits

Author SHA1 Message Date
jason jia 1e8fe2396e feat: 完善媒体维护、备份恢复与播放诊断 2026-10-20 14:01:42 +00:00
truewhile e34e81cce0 Merge pull request #48 from truewhile/fix/media-access-cache-search
修复实时事件权限和媒体缓存隔离,移除大库列表截断并缓存排序索引,批量同步 OpenSearch 并处理逐项失败。
2026-10-20 20:40:49 +08:00
jason jia 3cf898756b fix media event access, cache isolation and indexing 2026-10-20 12:39:13 +00:00
truewhile 0b68397265 Merge pull request #46 from truewhile/fix/introdb-api-v4
迁移 TheIntroDB 接口到 v4
2026-10-19 22:26:33 +08:00
truewhile 90bfac3b09 迁移 TheIntroDB 接口到 v4
v1 已删除、v2/v3 已弃用,仅保留只读的 GET /media。本次把 base URL 切到 v4,并适配响应以 tvdb_id 为身份(tmdb_id 字段已移除)。请求仍用 tmdb_id 寻址,v4 仍接受并由服务端解析季集映射。
2026-10-19 22:23:06 +08:00
truewhile f1974a179e 优化 2026-10-19 21:38:55 +08:00
truewhile f2357200be 优化 2026-10-19 21:11:35 +08:00
truewhile ca9a80b862 优化 2026-10-18 22:30:02 +08:00
truewhile 4f19e2210e feat: 字幕轨名称可读化
外挂字幕原来直接拿文件名的语言后缀当名称(chs、chs[BilibiliID_danmu]),
内嵌字幕没有 title 标签时也是裸语言码(chi)。改成统一映射成中文名:
chi/zh -> 中文,chs -> 简体中文,jpn -> 日本語,并把弹幕转存的
[BilibiliID_danmu] 文件标注为「弹幕」,避免和对白字幕混淆。

认不出来的标记保持原样(含 und),Emby 客户端的「字幕 (ASS)」兜底不变。
2026-10-18 17:01:08 +08:00
truewhile 91667c400c bug处理 2026-10-18 15:37:31 +08:00
truewhile 0b8438d798 Merge pull request #45 from truewhile/feature/android-mobile-api
优化
2026-10-18 10:45:05 +08:00
truewhile 2faedae0bf 优化 2026-10-18 10:43:43 +08:00
96 changed files with 4520 additions and 437 deletions
+3
View File
@@ -79,6 +79,9 @@ func (a *application) start() error {
}
}
if err := database.ApplyPendingSQLiteRestore(context.Background(), a.cfg, a.logger); err != nil {
return fmt.Errorf("apply pending database restore: %w", err)
}
db, err := database.Open(a.cfg, a.logger)
if err != nil {
a.logger.Error("database open failed", zap.Error(err))
+189
View File
@@ -0,0 +1,189 @@
package database
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
)
var sqliteRestoreMu sync.Mutex
func sqliteRestorePath(cfg *config.Config) (string, error) {
if cfg == nil || strings.TrimSpace(cfg.Database.DBPath) == "" || cfg.Database.DBPath == ":memory:" || strings.HasPrefix(cfg.Database.DBPath, "file:") {
return "", errors.New("恢复需要配置有效的 SQLite 数据库文件路径")
}
return filepath.Abs(cfg.Database.DBPath)
}
func sqliteRestoreDSN(path, mode string) string {
u := url.URL{Scheme: "file", Path: filepath.ToSlash(path)}
q := url.Values{"mode": {mode}, "_pragma": {"busy_timeout(5000)"}}
u.RawQuery = q.Encode()
return u.String()
}
func validateSQLiteRestore(ctx context.Context, path string) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return errors.New("备份必须是常规文件")
}
db, err := sql.Open("sqlite", sqliteRestoreDSN(path, "ro"))
if err != nil {
return err
}
defer db.Close()
var result string
if err := db.QueryRowContext(ctx, "PRAGMA quick_check").Scan(&result); err != nil {
return fmt.Errorf("SQLite 备份校验失败: %w", err)
}
if result != "ok" {
return fmt.Errorf("SQLite 备份校验失败: %s", result)
}
var tables int
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('users', 'media', 'settings')").Scan(&tables); err != nil {
return err
}
if tables == 0 {
return errors.New("备份不包含 MeBox 数据表")
}
return nil
}
// StageSQLiteRestore leaves the live database and its WAL untouched. The
// validated copy is applied on the next startup, before any database is opened.
func StageSQLiteRestore(ctx context.Context, cfg *config.Config, source string) error {
sqliteRestoreMu.Lock()
defer sqliteRestoreMu.Unlock()
dbPath, err := sqliteRestorePath(cfg)
if err != nil {
return err
}
pending := dbPath + ".restore-pending"
if _, err := os.Lstat(pending); err == nil {
return errors.New("已有等待恢复的备份,请重启 MeBox 后再操作")
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err := validateSQLiteRestore(ctx, source); err != nil {
return err
}
in, err := os.Open(source)
if err != nil {
return err
}
defer in.Close()
out, err := os.CreateTemp(filepath.Dir(dbPath), ".mebox-restore-*")
if err != nil {
return err
}
tmp := out.Name()
defer os.Remove(tmp)
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return err
}
if err := out.Sync(); err != nil {
_ = out.Close()
return err
}
if err := out.Close(); err != nil {
return err
}
if err := validateSQLiteRestore(ctx, tmp); err != nil {
return err
}
return os.Rename(tmp, pending)
}
// ApplyPendingSQLiteRestore must run before database.Open and before workers or
// HTTP listeners start. The previous database is retained alongside the file.
func ApplyPendingSQLiteRestore(ctx context.Context, cfg *config.Config, log *zap.Logger) error {
if cfg == nil {
return nil
}
dialect := normalizeDatabaseType(cfg.Database.Type)
if dialect == "auto" {
dialect = effectiveAutoDatabaseType(cfg)
}
if dialect != "sqlite" {
return nil
}
sqliteRestoreMu.Lock()
defer sqliteRestoreMu.Unlock()
dbPath, err := sqliteRestorePath(cfg)
if err != nil {
return err
}
pending := dbPath + ".restore-pending"
if _, err := os.Lstat(pending); errors.Is(err, os.ErrNotExist) {
return nil
} else if err != nil {
return err
}
if err := validateSQLiteRestore(ctx, pending); err != nil {
return err
}
previous := ""
if _, err := os.Lstat(dbPath); err == nil {
// A clean checkpoint prevents an old WAL from being replayed into the
// replacement database. A busy checkpoint leaves all files untouched.
live, err := sql.Open("sqlite", sqliteRestoreDSN(dbPath, "rw"))
if err != nil {
return err
}
var busy, pages, copied int
err = live.QueryRowContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)").Scan(&busy, &pages, &copied)
closeErr := live.Close()
if err != nil {
return err
}
if closeErr != nil {
return closeErr
}
if busy != 0 {
return errors.New("SQLite 数据库仍被占用,恢复已保留等待下次启动")
}
old, err := os.CreateTemp(filepath.Dir(dbPath), filepath.Base(dbPath)+".before-restore-*")
if err != nil {
return err
}
previous = old.Name()
if err := old.Close(); err != nil {
return err
}
if err := os.Remove(previous); err != nil {
return err
}
if err := os.Rename(dbPath, previous); err != nil {
return err
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.Rename(pending, dbPath); err != nil {
if previous != "" {
if rollbackErr := os.Rename(previous, dbPath); rollbackErr != nil {
return fmt.Errorf("恢复失败: %w;回退失败: %v,原数据库保留于 %s", err, rollbackErr, previous)
}
}
return err
}
if log != nil {
log.Warn("SQLite backup restored before startup", zap.String("previous_database", previous))
}
return nil
}
+104
View File
@@ -0,0 +1,104 @@
package database
import (
"database/sql"
"os"
"path/filepath"
"testing"
"github.com/truewhile/MeBox/internal/config"
)
func TestSQLiteRestoreStagesWithoutChangingLiveDatabase(t *testing.T) {
root := t.TempDir()
cfg := &config.Config{}
cfg.Database.Type = "sqlite"
cfg.Database.DBPath = filepath.Join(root, "live.db")
live, err := sql.Open("sqlite", sqliteRestoreDSN(cfg.Database.DBPath, "rwc"))
if err != nil {
t.Fatal(err)
}
defer live.Close()
for _, statement := range []string{
"PRAGMA journal_mode=WAL", "CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)",
"INSERT INTO settings VALUES ('state', 'backup')",
} {
if _, err := live.Exec(statement); err != nil {
t.Fatal(err)
}
}
source := filepath.Join(root, "backup.db")
if _, err := live.Exec("VACUUM INTO ?", source); err != nil {
t.Fatal(err)
}
if _, err := live.Exec("UPDATE settings SET value = 'live'"); err != nil {
t.Fatal(err)
}
if err := StageSQLiteRestore(t.Context(), cfg, source); err != nil {
t.Fatal(err)
}
var value string
if err := live.QueryRow("SELECT value FROM settings").Scan(&value); err != nil || value != "live" {
t.Fatalf("staging changed live database: value=%q err=%v", value, err)
}
if _, err := live.Exec("UPDATE settings SET value = 'after-stage'"); err != nil {
t.Fatalf("database could not write after staging: %v", err)
}
if err := StageSQLiteRestore(t.Context(), cfg, source); err == nil {
t.Fatal("second staging should not replace the pending restore")
}
if err := os.Remove(source); err != nil {
t.Fatal(err)
}
if err := live.Close(); err != nil {
t.Fatal(err)
}
if err := ApplyPendingSQLiteRestore(t.Context(), cfg, nil); err != nil {
t.Fatal(err)
}
restored, err := sql.Open("sqlite", sqliteRestoreDSN(cfg.Database.DBPath, "ro"))
if err != nil {
t.Fatal(err)
}
defer restored.Close()
if err := restored.QueryRow("SELECT value FROM settings").Scan(&value); err != nil || value != "backup" {
t.Fatalf("restore failed: value=%q err=%v", value, err)
}
previous, err := filepath.Glob(cfg.Database.DBPath + ".before-restore-*")
if err != nil || len(previous) != 1 {
t.Fatalf("previous database not preserved: files=%v err=%v", previous, err)
}
old, err := sql.Open("sqlite", sqliteRestoreDSN(previous[0], "ro"))
if err != nil {
t.Fatal(err)
}
defer old.Close()
if err := old.QueryRow("SELECT value FROM settings").Scan(&value); err != nil || value != "after-stage" {
t.Fatalf("old database lost latest writes: value=%q err=%v", value, err)
}
if err := ApplyPendingSQLiteRestore(t.Context(), cfg, nil); err != nil {
t.Fatalf("repeat startup: %v", err)
}
}
func TestSQLiteRestoreRejectsCorruptAndSymlinkBackups(t *testing.T) {
root := t.TempDir()
cfg := &config.Config{}
cfg.Database.DBPath = filepath.Join(root, "live.db")
source := filepath.Join(root, "corrupt.db")
if err := os.WriteFile(source, []byte("not a SQLite database"), 0o600); err != nil {
t.Fatal(err)
}
if err := StageSQLiteRestore(t.Context(), cfg, source); err == nil {
t.Fatal("corrupt backup accepted")
}
link := filepath.Join(root, "link.db")
if err := os.Symlink(source, link); err == nil {
if err := StageSQLiteRestore(t.Context(), cfg, link); err == nil {
t.Fatal("symlink backup accepted")
}
}
if _, err := os.Stat(cfg.Database.DBPath + ".restore-pending"); !os.IsNotExist(err) {
t.Fatalf("failed validation left pending data: %v", err)
}
}
+18
View File
@@ -0,0 +1,18 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
func backgroundTasksHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc == nil || svc.Tasks == nil {
c.JSON(http.StatusOK, service.TaskSnapshot{Active: []service.BackgroundTask{}, Recent: []service.BackgroundTask{}})
return
}
c.JSON(http.StatusOK, svc.Tasks.Snapshot())
}
}
+18 -1
View File
@@ -3,6 +3,7 @@ package handler
import (
"net/http"
"path/filepath"
"github.com/gin-gonic/gin"
@@ -41,12 +42,28 @@ func deleteBackupHandler(svc *service.Container) gin.HandlerFunc {
}
}
func downloadBackupHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
filename := c.Query("filename")
path, err := svc.Backup.ResolvePath(filename)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.FileAttachment(path, filepath.Base(path))
}
}
func restoreBackupHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Backup.Restore(c.Request.Context(), c.Query("filename")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "restored — restart the server to apply"})
message := "数据库已恢复,请重启 MeBox 以刷新运行时配置"
if svc.Repo.DB.Dialector.Name() == "sqlite" {
message = "备份已校验并暂存,请重启 MeBox 后应用恢复"
}
c.JSON(http.StatusOK, gin.H{"message": message, "restart_required": true})
}
}
+26
View File
@@ -197,6 +197,32 @@ func deleteEmbyMountHandler(svc *service.Container) gin.HandlerFunc {
}
}
// setEmbyMountsEnabledHandler 一键启用/停用某账号下的全部挂载。
//
// 远端整站不可用时(如 Cloudflare 522),管理员可以用它把该账号的挂载
// 全部从媒体库摘掉,首页立刻只返回本地库;远端恢复后再一键启用。
func setEmbyMountsEnabledHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
Enabled *bool `json:"enabled" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if svc.EmbyRemote == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "emby remote service not available"})
return
}
affected, err := svc.EmbyRemote.SetMountsEnabled(c.Request.Context(), c.Param("id"), *req.Enabled)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "affected": affected})
}
}
type reorderEmbyMountsReq struct {
IDs []string `json:"ids" binding:"required"`
}
@@ -0,0 +1,108 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// TestSetEmbyMountsEnabledHandler 覆盖「一键停用/启用某账号下全部挂载」:
// 只影响目标账号,且配置保留(不是删除)。
func TestSetEmbyMountsEnabledHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
ctx := t.Context()
m1 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-1", Enabled: true}
m2 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-2", Enabled: true}
other := &model.EmbyMount{AccountID: "acct-2", RemoteViewID: "view-3", Enabled: true}
for _, m := range []*model.EmbyMount{m1, m2, other} {
if err := repos.EmbyMount.Create(ctx, m); err != nil {
t.Fatalf("create mount: %v", err)
}
}
svc := &service.Container{
Repo: repos,
EmbyRemote: service.NewEmbyRemoteService(nil, zap.NewNop(), repos, nil),
}
router := gin.New()
router.PUT("/admin/emby/accounts/:id/mounts/enabled", setEmbyMountsEnabledHandler(svc))
call := func(body map[string]any) *httptest.ResponseRecorder {
raw, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPut, "/admin/emby/accounts/acct-1/mounts/enabled", bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
w := call(map[string]any{"enabled": false})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
OK bool `json:"ok"`
Affected int64 `json:"affected"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if !resp.OK || resp.Affected != 2 {
t.Fatalf("expected ok with affected=2, got %+v", resp)
}
rows, err := repos.EmbyMount.List(ctx)
if err != nil {
t.Fatal(err)
}
for _, row := range rows {
want := row.AccountID != "acct-1" // 目标账号停用,其他账号不受影响
if row.Enabled != want {
t.Fatalf("mount %s enabled=%v, want %v", row.ID, row.Enabled, want)
}
}
// 重新启用:挂载记录仍在,只是开关回 true。
w = call(map[string]any{"enabled": true})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
rows, err = repos.EmbyMount.List(ctx)
if err != nil {
t.Fatal(err)
}
if len(rows) != 3 {
t.Fatalf("expected mounts to be preserved, got %d rows", len(rows))
}
for _, row := range rows {
if !row.Enabled {
t.Fatalf("mount %s should be enabled again", row.ID)
}
}
// 缺少 enabled 字段必须拒绝,避免误把全部挂载停掉。
if w := call(map[string]any{}); w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for missing enabled, got %d: %s", w.Code, w.Body.String())
}
}
+148 -109
View File
@@ -6,6 +6,7 @@ import (
"errors"
"math"
"net/http"
"sort"
"strconv"
"strings"
"sync"
@@ -556,19 +557,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 +655,9 @@ func paginateSlice[T any](items []T, page, size int) []T {
if len(items) == 0 {
return []T{}
}
if page-1 > len(items)/size {
return []T{}
}
start := (page - 1) * size
if start >= len(items) {
return []T{}
@@ -670,127 +672,164 @@ func paginateSlice[T any](items []T, page, size int) []T {
func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
q := c.Query("q")
query := strings.TrimSpace(c.Query("q"))
visibility := mediaVisibilityForRequest(c, svc)
groupVersions := c.DefaultQuery("group_versions", "1") != "0"
fetchRemote := func(limit int) []model.Media {
if svc.EmbyRemote == nil || strings.TrimSpace(q) == "" {
return nil
}
remoteItems, _ := svc.EmbyRemote.RemoteSearchMedia(ctx, q, limit, visibility)
return remoteItems
filter := service.MediaSearchFilter{
LibraryID: strings.TrimSpace(c.Query("library_id")),
YearMin: boundedSearchYear(c.Query("year_min")),
YearMax: boundedSearchYear(c.Query("year_max")),
}
if c.DefaultQuery("group_series", "0") != "0" {
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
for _, genre := range c.QueryArray("genre") {
if genre = strings.TrimSpace(genre); genre != "" && len(filter.Genres) < 8 {
filter.Genres = append(filter.Genres, genre)
}
remoteItems := fetchRemote(50000)
all := service.GroupMediaSeriesItems(append(localItems, remoteItems...))
if c.Query("page") != "" || c.Query("page_size") != "" {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
paged := paginateSlice(all, page, size)
c.JSON(http.StatusOK, gin.H{
"items": paged,
"total": len(all),
"page": page,
"page_size": size,
})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit <= 0 {
limit = 50
}
if len(all) > limit {
all = all[:limit]
}
c.JSON(http.StatusOK, gin.H{"items": all})
return
}
if c.Query("page") != "" || c.Query("page_size") != "" {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
if !groupVersions {
localItems, _, err := svc.Media.SearchMediaVisiblePage(ctx, q, 1, 50000, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(size * 2)
all := append(localItems, remoteItems...)
paged := paginateSlice(all, page, size)
c.JSON(http.StatusOK, gin.H{
"items": paged,
"total": len(all),
"page": page,
"page_size": size,
})
return
}
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(size * 2)
all := append(localItems, remoteItems...)
grouped := service.GroupMediaVersions(all)
paged := service.PaginateMediaItems(grouped, page, size)
c.JSON(http.StatusOK, gin.H{
"items": paged,
"total": len(grouped),
"page": page,
"page_size": size,
})
return
if filter.YearMin > 0 && filter.YearMax > 0 && filter.YearMin > filter.YearMax {
filter.YearMin, filter.YearMax = filter.YearMax, filter.YearMin
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
if size < 1 {
size = 50
} else if size > 2000 {
size = 2000
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit <= 0 {
if limit < 1 {
limit = 50
} else if limit > 50000 {
limit = 50000
}
if !groupVersions {
localItems, err := svc.Media.SearchMediaVisible(ctx, q, limit, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(limit)
all := append(localItems, remoteItems...)
if len(all) > limit {
all = all[:limit]
}
if all == nil {
all = []model.Media{}
}
c.JSON(http.StatusOK, gin.H{"items": all})
return
}
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
paginated := c.Query("page") != "" || c.Query("page_size") != ""
sortBy := normalizeMediaSearchSort(c.Query("sort"))
// Sorting and grouping must operate on the same candidate set before
// either a page size or the caller's output limit is applied.
items, err := svc.Media.SearchMediaVisibleFiltered(ctx, query, 50000, visibility, filter)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(limit)
all := append(localItems, remoteItems...)
grouped := service.GroupMediaVersions(all)
items := service.FirstMediaItems(grouped, limit)
if items == nil {
items = []service.MediaItem{}
if svc.EmbyRemote != nil && query != "" && (filter.LibraryID == "" || service.IsEmbyRemoteID(filter.LibraryID)) {
remote, _ := svc.EmbyRemote.RemoteSearchMedia(ctx, query, 50000, visibility)
for _, item := range remote {
if remoteSearchMatchesFilter(item, filter) {
items = append(items, item)
}
}
}
c.JSON(http.StatusOK, gin.H{"items": items})
if c.DefaultQuery("group_series", "0") != "0" {
items = service.GroupMediaSeriesItems(items)
sortSearchMedia(items, sortBy)
writeMediaSearchResults(c, items, page, size, limit, paginated)
return
}
if c.DefaultQuery("group_versions", "1") != "0" {
grouped := service.GroupMediaVersions(items)
if sortBy != "" {
sort.SliceStable(grouped, func(i, j int) bool {
return mediaSearchSortLess(grouped[i].Media, grouped[j].Media, sortBy)
})
}
writeMediaSearchResults(c, grouped, page, size, limit, paginated)
return
}
sortSearchMedia(items, sortBy)
writeMediaSearchResults(c, items, page, size, limit, paginated)
}
}
func writeMediaSearchResults[T any](c *gin.Context, items []T, page, size, limit int, paginated bool) {
if items == nil {
items = []T{}
}
if paginated {
c.JSON(http.StatusOK, gin.H{
"items": paginateSlice(items, page, size),
"total": len(items), "page": page, "page_size": size,
})
return
}
if len(items) > limit {
items = items[:limit]
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func remoteSearchMatchesFilter(item model.Media, filter service.MediaSearchFilter) bool {
if filter.LibraryID != "" && item.LibraryID != filter.LibraryID && item.DisplayLibraryID != filter.LibraryID {
return false
}
if filter.YearMin > 0 && item.Year < filter.YearMin || filter.YearMax > 0 && item.Year > filter.YearMax {
return false
}
if len(filter.Genres) == 0 {
return true
}
normalize := func(value string) string {
return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(value), " ", ""))
}
for _, genre := range strings.Split(strings.ReplaceAll(item.Genres, ",", ","), ",") {
for _, wanted := range filter.Genres {
if normalize(genre) == normalize(wanted) {
return true
}
}
}
return false
}
func normalizeMediaSearchSort(value string) string {
value = strings.TrimSpace(value)
switch value {
case "title_asc", "year_desc", "rating_desc":
return value
default:
return ""
}
}
func sortSearchMedia(items []model.Media, sortBy string) {
if sortBy == "" {
return
}
sort.SliceStable(items, func(i, j int) bool {
return mediaSearchSortLess(items[i], items[j], sortBy)
})
}
func mediaSearchSortLess(a, b model.Media, sortBy string) bool {
switch sortBy {
case "title_asc":
left, right := strings.ToLower(strings.TrimSpace(a.Title)), strings.ToLower(strings.TrimSpace(b.Title))
if left != right {
return left < right
}
case "year_desc":
if a.Year != b.Year {
return a.Year > b.Year
}
case "rating_desc":
if a.Rating != b.Rating {
return a.Rating > b.Rating
}
}
if !a.CreatedAt.Equal(b.CreatedAt) {
return a.CreatedAt.After(b.CreatedAt)
}
return a.ID < b.ID
}
func boundedSearchYear(value string) int {
year, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || year < 1870 || year > 2200 {
return 0
}
return year
}
func streamHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
+37
View File
@@ -0,0 +1,37 @@
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
func mediaQualityHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
if page < 1 {
page = 1
} else if page > 1000000 {
page = 1000000
}
if pageSize < 1 {
pageSize = 50
} else if pageSize > 100 {
pageSize = 100
}
issue := c.DefaultQuery("issue", "unmatched")
if issue != "unmatched" && issue != "poster" && issue != "missing" {
c.JSON(http.StatusBadRequest, gin.H{"error": "未知检查分类"})
return
}
items, total, err := svc.Media.QualityIssues(c.Request.Context(), issue, page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": pageSize})
}
}
@@ -0,0 +1,88 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestSearchMediaSortLimitFiltersAndDeepPagination(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
defer sqlDB.Close()
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.User{}, &model.Setting{}, &model.PlayProfile{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
lib := model.Library{Name: "Review", Path: "/review", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
rows := make([]model.Media, 2001)
for i := range rows {
rows[i] = model.Media{
Base: model.Base{ID: fmt.Sprintf("review-%04d", i)}, LibraryID: lib.ID,
Title: fmt.Sprintf("Review %04d", i), Path: fmt.Sprintf("/review/%04d.mp4", i), Year: 2020, Genres: "Comedy",
}
}
rows[2000].Year = 2025
rows[2000].Genres = "Drama"
if err := db.CreateInBatches(rows, 100).Error; err != nil {
t.Fatal(err)
}
svc := &service.Container{Repo: repos, Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos)}
cases := []struct {
name, query, expectedID string
count, total, page, size int
}{
{name: "sorted output limit", query: "sort=year_desc&limit=1", expectedID: "review-2000", count: 1},
{name: "page past old 2000 cap", query: "sort=title_asc&page=2001&page_size=1", expectedID: "review-2000", count: 1, total: 2001, page: 2001, size: 1},
{name: "genre filtering", query: "genre=Drama&library_id=" + lib.ID, expectedID: "review-2000", count: 1},
{name: "normalized page", query: "page=0&page_size=0", count: 50, total: 2001, page: 1, size: 50},
{name: "bounded page size", query: "page=1&page_size=99999", count: 2000, total: 2001, page: 1, size: 2000},
{name: "huge page does not overflow", query: "page=" + strconv.Itoa(int(^uint(0)>>1)) + "&page_size=2000", count: 0, total: 2001, page: int(^uint(0) >> 1), size: 2000},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(w)
ctx.Set(middleware.CtxUserID, "admin")
ctx.Set(middleware.CtxUserRole, "admin")
ctx.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=Review&group_versions=0&"+tc.query, nil)
searchMediaHandler(svc)(ctx)
var response struct {
Items []model.Media `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"page_size"`
}
if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &response) != nil {
t.Fatalf("search failed: status=%d body=%s", w.Code, w.Body.String())
}
if len(response.Items) != tc.count || response.Total != tc.total || response.Page != tc.page || response.Size != tc.size {
t.Fatalf("response count=%d total=%d page=%d size=%d, want %#v", len(response.Items), response.Total, response.Page, response.Size, tc)
}
if tc.expectedID != "" && response.Items[0].ID != tc.expectedID {
t.Fatalf("first ID=%q want %q", response.Items[0].ID, tc.expectedID)
}
})
}
}
+8 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/gin-gonic/gin"
@@ -27,7 +28,8 @@ func TestSearchMediaSeriesKeepsRemoteLibrariesSeparate(t *testing.T) {
"TotalRecordCount": 1,
"Items": []map[string]any{{
"Id": "same-series", "Name": "龙珠改", "Type": "Series",
"ProviderIds": map[string]string{"Tmdb": "61709"},
"ProviderIds": map[string]string{"Tmdb": "61709"},
"ProductionYear": 2024, "Genres": []string{"Science Fiction"},
}},
})
}))
@@ -108,4 +110,9 @@ func TestSearchMediaSeriesKeepsRemoteLibrariesSeparate(t *testing.T) {
}
seen[item.DisplayLibraryID] = true
}
remoteLibraryID := service.EncodeEmbyRemoteID("mount-a", "view-a")
items, total := request("page=1&page_size=8&library_id=" + url.QueryEscape(remoteLibraryID) + "&year_min=2020&genre=Science+Fiction")
if total != 1 || len(items) != 1 || items[0].DisplayLibraryID != remoteLibraryID {
t.Fatalf("remote library/year/genre filters lost results: total=%d items=%#v", total, items)
}
}
+20 -2
View File
@@ -73,13 +73,31 @@ func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
subtitles = discovered
}
}
c.JSON(http.StatusOK, gin.H{
// 能在服务端换链就把最终直链一并返回:客户端直接连 CDN,省掉
// /api/stream 的 302 往返。跨境/高延迟链路上,这一跳等于一次完整的
// DNS + TCP + TLS,实测能到数秒,正好压在起播关键路径上。
// 换链失败、本地文件或需要服务端加头的网盘保持原样,客户端仍走
// stream_url 跳转,行为与改动前一致。
directURL, hasDirectURL := "", false
if svc.Stream != nil {
// `?refresh=1`:客户端手上的直链已失效(CDN 403),要求丢掉缓存
// 重新换链,别把同一条死链再发一次。
if strings.EqualFold(strings.TrimSpace(c.Query("refresh")), "1") {
service.InvalidateMediaLinkCache(m)
}
directURL, hasDirectURL = svc.Stream.ResolveDirectPlayURL(c.Request, m)
}
payload := gin.H{
"media": m,
"stream_url": "/api/stream/" + m.ID + "?token=" + url.QueryEscape(token) + profileQuery,
"hls_url": hlsURL,
"resume": resumePayload,
"subtitles": subtitles,
})
}
if hasDirectURL {
payload["direct_url"] = directURL
}
c.JSON(http.StatusOK, payload)
}
}
+55
View File
@@ -22,6 +22,7 @@ import (
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
"github.com/truewhile/MeBox/internal/service/cloud"
)
func TestExternalURLUsesMediaScopedPlaybackToken(t *testing.T) {
@@ -457,6 +458,60 @@ func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
}
}
// 服务端能换到最终直链时,播放地址响应要把直链一并给客户端:移动端起播就能
// 跳过 /api/stream 的 302 往返(跨境链路上等于一次完整 DNS+TCP+TLS)。
func TestPlaybackInfoCarriesPrefetchedDirectURL(t *testing.T) {
router, svc, secret := newPlaybackScopeTestRouter(t)
loginToken := signedTestToken(t, secret)
const direct = "https://cdnfhnfile.115cdn.net/637b/Movie.mkv?t=1&k=sig"
var gotUA string
svc.Stream.SetStrmPlayTargetResolver(func(_ context.Context, _, userAgent string) (*service.StrmPlayResult, error) {
gotUA = userAgent
return &service.StrmPlayResult{
RedirectURL: direct,
Link: &cloud.DirectLink{URL: direct, Headers: map[string]string{"User-Agent": userAgent}},
}, nil
})
if err := svc.Repo.DB.Create(&model.Media{
Base: model.Base{ID: "media-direct"},
LibraryID: "lib-1",
Title: "Direct",
Path: "cloud://cloud115/Movie.mkv",
Container: "strm",
DurationSec: 3600,
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1",
}).Error; err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/playback/media-direct/info", nil)
req.Header.Set("Authorization", "Bearer "+loginToken)
req.Header.Set("User-Agent", "MeBox-Android/0.1.0")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
var payload struct {
DirectURL string `json:"direct_url"`
StreamURL string `json:"stream_url"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode: %v", err)
}
if payload.DirectURL != direct {
t.Fatalf("direct_url = %q, want %q (body=%s)", payload.DirectURL, direct, w.Body.String())
}
if !strings.Contains(payload.StreamURL, "/api/stream/media-direct") {
t.Fatalf("stream_url must stay as the fallback, got %q", payload.StreamURL)
}
// 115 直链绑定换取时的 UA:预置直链必须按播放器的 UA 换,否则播放会 403。
if gotUA != "MeBox-Android/0.1.0" {
t.Fatalf("resolver UA = %q, want the client UA", gotUA)
}
}
func TestSubtitleListOnlyProbesEmbeddedTracksForHLS(t *testing.T) {
router, svc, secret := newPlaybackScopeTestRouter(t)
loginToken := signedTestToken(t, secret)
+3 -3
View File
@@ -149,10 +149,10 @@ func readerDeleteSourceHandler(svc *service.Container) gin.HandlerFunc {
}
func readerDebugSourceHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Key string `json:"key" binding:"required"`
}
return func(c *gin.Context) {
var body struct {
Key string `json:"key" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
+4
View File
@@ -13,6 +13,8 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.
admin := api.Group("/admin")
admin.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret), middleware.AdminRequired())
registerAdminUserRoutes(admin, svc)
admin.GET("/tasks", backgroundTasksHandler(svc))
admin.GET("/quality", mediaQualityHandler(svc))
registerAdminPermissionRoutes(admin, svc)
registerAdminSystemRoutes(admin, svc)
registerAdminBackupRoutes(admin, svc)
@@ -50,6 +52,7 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
// Emby 挂载管理:远程 Emby 媒体库挂载(账号复用 strm/accounts)
admin.GET("/emby/accounts/:id/views", embyAccountViewsHandler(svc))
admin.POST("/emby/accounts/:id/full-mount", fullMountEmbyAccountHandler(svc))
admin.PUT("/emby/accounts/:id/mounts/enabled", setEmbyMountsEnabledHandler(svc))
admin.GET("/emby/mounts", listEmbyMountsHandler(svc))
admin.POST("/emby/mounts", createEmbyMountsHandler(svc))
admin.PUT("/emby/mounts/reorder", reorderEmbyMountsHandler(svc))
@@ -142,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))
}
+8 -5
View File
@@ -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
+93
View File
@@ -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)
}
}
+23
View File
@@ -0,0 +1,23 @@
package model
import "time"
// BackgroundTaskRecord persists the operator-facing lifecycle of scan,
// organize, and other long-running application tasks.
type BackgroundTaskRecord struct {
ID string `gorm:"primaryKey;size:36" json:"id"`
Kind string `gorm:"index;size:32;not null" json:"kind"`
Name string `gorm:"size:255;not null" json:"name"`
Status string `gorm:"index;size:24;not null" json:"status"`
Stage string `gorm:"size:64" json:"stage"`
SourcePath string `gorm:"size:2048" json:"source_path"`
DestPath string `gorm:"size:2048" json:"dest_path"`
Message string `gorm:"type:text" json:"message"`
Error string `gorm:"type:text" json:"error"`
Details string `gorm:"type:text" json:"details"`
Metrics string `gorm:"type:text" json:"metrics"`
Items string `gorm:"type:text" json:"items"`
StartedAt time.Time `gorm:"index" json:"started_at"`
UpdatedAt time.Time `json:"updated_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
}
+11 -6
View File
@@ -1,5 +1,7 @@
package model
import "time"
// Library 表示一个逻辑媒体库。Path 保留为兼容字段,指向第一条 LibraryRoot。
type Library struct {
Base
@@ -26,12 +28,15 @@ type LibraryRoot struct {
// Media 是单个可播放项。剧集链接到 SeriesID;电影 SeriesID == ""。
type Media struct {
Base
LibraryID string `gorm:"index;size:36;index:idx_media_library_release,priority:1" json:"library_id"`
LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"`
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
Title string `gorm:"size:255;not null" json:"title"`
OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"`
AvailabilityState string `gorm:"index;size:16;not null;default:available" json:"availability_state"`
MissingSince *time.Time `json:"missing_since,omitempty"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
LibraryID string `gorm:"index;size:36;index:idx_media_library_release,priority:1" json:"library_id"`
LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"`
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
Title string `gorm:"size:255;not null" json:"title"`
OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"`
// Path 的唯一性由 database 包按方言显式建索引,这里刻意不用 uniqueIndex:
// PostgreSQL 的 btree 单条索引项上限 2704 字节,而 varchar(1024) 在中文路径
// 下可达 3072 字节,直接对整列建唯一索引会让长路径的扫描插入直接失败
+1
View File
@@ -61,6 +61,7 @@ func AllModels() []interface{} {
&StrmUploadTask{},
&StrmDirCache{},
&ScrapeTask{},
&BackgroundTaskRecord{},
&EmbyMount{},
&ReaderBookSource{},
&ReaderSourceState{},
@@ -3,6 +3,7 @@ package repository
import (
"context"
"errors"
"strings"
"time"
"gorm.io/gorm"
@@ -89,6 +90,22 @@ func (r *EmbyMountRepository) SetSortOrder(ctx context.Context, ids []string) er
})
}
// SetEnabledByAccountID 批量设置账号下全部挂载的启用状态。
func (r *EmbyMountRepository) SetEnabledByAccountID(ctx context.Context, accountID string, enabled bool) (int64, error) {
var affected int64
if strings.TrimSpace(accountID) == "" {
return 0, nil
}
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.EmbyMount{}).
Where("account_id = ?", accountID).
Updates(map[string]any{"enabled": enabled, "updated_at": time.Now()})
affected = res.RowsAffected
return res.Error
})
return affected, err
}
func (r *EmbyMountRepository) CountByAccountID(ctx context.Context, accountID string) (int64, error) {
var count int64
err := r.db.WithContext(ctx).Model(&model.EmbyMount{}).Where("account_id = ?", accountID).Count(&count).Error
-8
View File
@@ -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
+38 -3
View File
@@ -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)
+119 -4
View File
@@ -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)
+164
View File
@@ -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)
}
}
@@ -46,16 +46,20 @@ func (r *RefreshTokenRepository) RevokeByUserID(ctx context.Context, userID stri
})
}
// RevokeOldestActiveByUserID keeps at most limit active refresh tokens for a
// user by revoking the oldest non-expired, non-revoked tokens.
func (r *RefreshTokenRepository) RevokeOldestActiveByUserID(ctx context.Context, userID string, limit int) error {
// RevokeOldestActiveLegacyByUserID keeps at most limit active refresh tokens
// for a user among tokens that are not bound to a device auth session
// (legacy web clients). Device sessions are independent: trimming one
// device's token because another device logged in would silently sign the
// first device out even though its session is still valid.
func (r *RefreshTokenRepository) RevokeOldestActiveLegacyByUserID(ctx context.Context, userID string, limit int) error {
if limit < 1 {
limit = 1
}
return withSQLiteBusyRetry(ctx, func() error {
var tokens []model.RefreshToken
if err := r.db.WithContext(ctx).
Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, time.Now()).
Where("user_id = ? AND revoked = ? AND expires_at > ? AND (auth_session_id IS NULL OR auth_session_id = '')",
userID, false, time.Now()).
Order("created_at desc, id desc").
Find(&tokens).Error; err != nil {
return err
+47
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"errors"
"fmt"
"testing"
"time"
@@ -163,6 +164,52 @@ func TestRefreshKeepsSessionAssociation(t *testing.T) {
}
}
// TestMobileDeviceSessionsDoNotTrimEachOther 覆盖同一账号多设备登录:
// 每台设备有独立认证会话与刷新令牌,新设备登录或任一设备刷新都不应撤销
// 其他设备的刷新令牌。历史实现把「最大同时登录终端」直接作用在刷新令牌
// 上,第 N+1 台设备登录会把最早那台设备的刷新令牌标记为已撤销,那台设备
// 下一次刷新就会收到 SESSION_REVOKED,被强制退回登录页。
func TestMobileDeviceSessionsDoNotTrimEachOther(t *testing.T) {
sessions, tokens, repos := newAuthSessionTestEnv(t)
ctx := context.Background()
user := seedSessionUser(t, repos)
// 默认上限是 3,这里故意登录 4 台设备。
const devices = 4
pairs := make([]*TokenPair, 0, devices)
for i := 0; i < devices; i++ {
session, err := sessions.Create(ctx, CreateAuthSessionInput{
UserID: user.ID,
DeviceID: fmt.Sprintf("device-%02d", i),
DeviceName: fmt.Sprintf("Phone %d", i+1),
})
if err != nil {
t.Fatalf("create session %d: %v", i+1, err)
}
pair, err := tokens.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
if err != nil {
t.Fatalf("issue pair %d: %v", i+1, err)
}
pairs = append(pairs, pair)
}
for i, pair := range pairs {
row, err := repos.RefreshToken.FindByHash(ctx, repository.HashToken(pair.RefreshToken))
if err != nil {
t.Fatalf("load device %d token: %v", i+1, err)
}
if row == nil {
t.Fatalf("device %d refresh token missing", i+1)
}
if row.Revoked {
t.Fatalf("device %d refresh token was revoked by another device", i+1)
}
if _, err := tokens.Refresh(ctx, pair.RefreshToken); err != nil {
t.Fatalf("device %d refresh failed: %v", i+1, err)
}
}
}
func TestServerIdentityPersistsAcrossInstances(t *testing.T) {
db := newServiceTestDB(t, &model.Setting{})
repos := repository.New(db)
+81 -37
View File
@@ -5,7 +5,7 @@
//
// 两种方言的备份形态不同:
// - SQLite:用 "VACUUM INTO"(3.27.0 起可用)产出一个一致的库文件,
// 扩展名 .db;恢复即「替换库文件 + 重启」。
// 扩展名 .db;恢复先暂存,在下次启动打开数据库前替换文件。
// - PostgreSQL:没有单文件语义,用逻辑备份(见 backup_postgres.go),
// 扩展名 .json.gz;恢复是在单个事务内重建全部表。
package service
@@ -24,6 +24,7 @@ import (
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/database"
)
// BackupService manages database backups.
@@ -44,6 +45,7 @@ type BackupInfo struct {
Path string `json:"path"`
Size int64 `json:"size"`
CreatedAt time.Time `json:"created_at"`
Automatic bool `json:"automatic"`
}
func (b *BackupService) backupDir() string {
@@ -69,6 +71,23 @@ func (b *BackupService) backupFilePath(filename string) (string, error) {
return path, nil
}
// ResolvePath returns the absolute path of a listed backup after applying the
// same filename and directory checks used by restore and delete operations.
func (b *BackupService) ResolvePath(filename string) (string, error) {
path, err := b.backupFilePath(filename)
if err != nil {
return "", err
}
info, err := os.Lstat(path)
if err != nil {
return "", err
}
if !info.Mode().IsRegular() {
return "", errors.New("backup is not a regular file")
}
return path, nil
}
// isPostgres reports whether the service is backed by PostgreSQL.
//
// PostgreSQL 不支持 SQLite 的 "VACUUM INTO",按文件替换恢复也会写错目标,
@@ -80,11 +99,23 @@ func (b *BackupService) isPostgres() bool {
// Create produces a new hot backup. SQLite uses "VACUUM INTO"; PostgreSQL uses
// a logical dump.
func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) {
return b.create(ctx, false)
}
func (b *BackupService) CreateAutomatic(ctx context.Context) (*BackupInfo, error) {
return b.create(ctx, true)
}
func (b *BackupService) create(ctx context.Context, automatic bool) (*BackupInfo, error) {
dir := b.backupDir()
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, err
}
ts := time.Now().Format("20060102_150405")
now := time.Now()
ts := fmt.Sprintf("%s_%09d", now.Format("20060102_150405"), now.Nanosecond())
if automatic {
ts = "auto_" + ts
}
var (
dst string
@@ -108,15 +139,24 @@ func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) {
Path: dst,
Size: stat.Size(),
CreatedAt: stat.ModTime(),
Automatic: automatic,
}, nil
}
func (b *BackupService) createSQLiteBackup(ctx context.Context, stamp string) (string, error) {
dst := filepath.Join(b.backupDir(), fmt.Sprintf("mebox_%s.db", stamp))
// VACUUM INTO creates a clean, non-WAL copy atomically.
if err := b.db.WithContext(ctx).Exec("VACUUM INTO ?", dst).Error; err != nil {
tmp := dst + ".tmp"
defer os.Remove(tmp)
// Publish only completed backups, with credentials protected by file mode.
if err := b.db.WithContext(ctx).Exec("VACUUM INTO ?", tmp).Error; err != nil {
return "", fmt.Errorf("VACUUM INTO failed: %w", err)
}
if err := os.Chmod(tmp, 0o600); err != nil {
return "", err
}
if err := os.Rename(tmp, dst); err != nil {
return "", err
}
return dst, nil
}
@@ -132,7 +172,7 @@ func (b *BackupService) List() ([]BackupInfo, error) {
}
out := make([]BackupInfo, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !isBackupFilename(e.Name()) {
if !e.Type().IsRegular() || !isBackupFilename(e.Name()) {
continue
}
info, err := e.Info()
@@ -144,6 +184,7 @@ func (b *BackupService) List() ([]BackupInfo, error) {
Path: filepath.Join(dir, e.Name()),
Size: info.Size(),
CreatedAt: info.ModTime(),
Automatic: strings.HasPrefix(e.Name(), "mebox_auto_"),
})
}
sort.Slice(out, func(i, j int) bool {
@@ -152,6 +193,36 @@ func (b *BackupService) List() ([]BackupInfo, error) {
return out, nil
}
// Prune keeps maxCount automatic backups for the current database dialect.
// Manual backups and backups from another dialect are never pruned.
// A value below one disables pruning so an invalid setting cannot erase all
// recoverable copies.
func (b *BackupService) Prune(maxCount int) error {
if maxCount < 1 {
return nil
}
items, err := b.List()
if err != nil {
return err
}
eligible := items[:0]
for _, item := range items {
if item.Automatic && strings.HasSuffix(item.Filename, postgresBackupSuffix) == b.isPostgres() {
eligible = append(eligible, item)
}
}
items = eligible
if maxCount >= len(items) {
return nil
}
for _, item := range items[maxCount:] {
if err := b.Delete(item.Filename); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
}
return nil
}
// Delete removes a single backup file.
func (b *BackupService) Delete(filename string) error {
path, err := b.backupFilePath(filename)
@@ -161,12 +232,10 @@ func (b *BackupService) Delete(filename string) error {
return os.Remove(path)
}
// Restore replaces the live database with the given backup.
// WARNING: this is destructive. SQLite restores by swapping the database file
// (the caller should shut down the server afterwards); PostgreSQL restores by
// rebuilding every table inside a single transaction.
// Restore stages SQLite backups for the next startup. PostgreSQL restores
// rebuild every table inside a single transaction.
func (b *BackupService) Restore(ctx context.Context, filename string) error {
src, err := b.backupFilePath(filename)
src, err := b.ResolvePath(filename)
if err != nil {
return err
}
@@ -182,35 +251,10 @@ func (b *BackupService) Restore(ctx context.Context, filename string) error {
if !strings.HasSuffix(filename, ".db") {
return errors.New("该备份是 PostgreSQL 逻辑备份,当前运行在 SQLite 上,无法恢复")
}
return b.restoreSQLiteBackup(filename, src)
}
func (b *BackupService) restoreSQLiteBackup(filename, src string) error {
dbPath, err := filepath.Abs(b.cfg.Database.DBPath)
if err != nil || dbPath == "" {
return errors.New("invalid database path")
}
// Strategy: rename live → live.old, copy backup → live, delete old.
old := dbPath + ".before_restore"
if err := os.Rename(dbPath, old); err != nil && !errors.Is(err, os.ErrNotExist) {
if err := database.StageSQLiteRestore(ctx, b.cfg, src); err != nil {
return err
}
data, err := os.ReadFile(src) // #nosec G304 -- src is constrained by backupFilePath to the configured backups directory.
if err != nil {
// Revert
_ = os.Rename(old, dbPath)
return err
}
if err := os.WriteFile(dbPath, data, 0o600); err != nil { // #nosec G703 -- database path is trusted process config and normalized before restore.
_ = os.Rename(old, dbPath)
return err
}
_ = os.Remove(old)
// Also remove WAL/SHM so SQLite opens the fresh file cleanly.
_ = os.Remove(dbPath + "-wal")
_ = os.Remove(dbPath + "-shm")
b.log.Warn("database restored from backup — restart the server",
zap.String("backup", filename))
b.log.Warn("SQLite backup staged; restart to apply", zap.String("backup", filename))
return nil
}
+33 -12
View File
@@ -3,6 +3,7 @@ package service
import (
"compress/gzip"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
@@ -87,7 +88,8 @@ func postgresBackupModelSchemas(db *gorm.DB) (map[string]*schema.Schema, map[str
// createPostgresBackup 把全库导出成一个 gzip 压缩的 JSON Lines 文件。
func (b *BackupService) createPostgresBackup(ctx context.Context, stamp string) (string, error) {
dst := filepath.Join(b.backupDir(), "mebox_"+stamp+postgresBackupSuffix)
file, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
tmp := dst + ".tmp"
file, err := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return "", err
}
@@ -96,7 +98,7 @@ func (b *BackupService) createPostgresBackup(ctx context.Context, stamp string)
_ = file.Close()
if !complete {
// 半截备份文件比没有备份更危险:留着会让人以为有可用的还原点。
_ = os.Remove(dst)
_ = os.Remove(tmp)
}
}()
@@ -109,17 +111,30 @@ func (b *BackupService) createPostgresBackup(ctx context.Context, stamp string)
}); err != nil {
return "", fmt.Errorf("写入备份头失败: %w", err)
}
for _, m := range model.AllModels() {
table, sch, err := parseModelSchema(b.db, m)
if err != nil {
return "", err
}
if !b.db.Migrator().HasTable(table) {
continue
}
if err := b.dumpPostgresTable(ctx, m, sch, table, enc); err != nil {
return "", err
var options []*sql.TxOptions
if b.isPostgres() {
options = append(options, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
}
// Every table must come from the same snapshot, including while background
// workers are inserting related rows during an automatic backup.
if err := b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
snapshot := *b
snapshot.db = tx
for _, m := range model.AllModels() {
table, sch, err := parseModelSchema(tx, m)
if err != nil {
return err
}
if !tx.Migrator().HasTable(table) {
continue
}
if err := snapshot.dumpPostgresTable(ctx, m, sch, table, enc); err != nil {
return err
}
}
return nil
}, options...); err != nil {
return "", err
}
if err := zw.Close(); err != nil {
return "", fmt.Errorf("压缩备份失败: %w", err)
@@ -127,6 +142,12 @@ func (b *BackupService) createPostgresBackup(ctx context.Context, stamp string)
if err := file.Sync(); err != nil {
return "", fmt.Errorf("写入备份失败: %w", err)
}
if err := file.Close(); err != nil {
return "", err
}
if err := os.Rename(tmp, dst); err != nil {
return "", err
}
complete = true
return dst, nil
}
+65
View File
@@ -0,0 +1,65 @@
package service
import (
"os"
"path/filepath"
"runtime"
"testing"
"time"
)
func TestBackupRetentionPreservesManualAndOtherDialectBackups(t *testing.T) {
svc, _ := newBackupTestService(t)
names := []string{"mebox_auto_new.db", "mebox_auto_old.db", "mebox_manual.db", "mebox_auto_postgres.json.gz"}
for i, name := range names {
path := filepath.Join(svc.backupDir(), name)
if err := os.WriteFile(path, []byte("fixture"), 0o600); err != nil {
t.Fatal(err)
}
when := time.Now().Add(-time.Duration(i) * time.Hour)
if err := os.Chtimes(path, when, when); err != nil {
t.Fatal(err)
}
}
if err := svc.Prune(1); err != nil {
t.Fatal(err)
}
for _, name := range names {
_, err := os.Stat(filepath.Join(svc.backupDir(), name))
if name == "mebox_auto_old.db" {
if !os.IsNotExist(err) {
t.Fatalf("old automatic backup was retained: %v", err)
}
} else if err != nil {
t.Fatalf("backup %s should have been preserved: %v", name, err)
}
}
}
func TestBackupCreateUsesUniquePrivateCompletedFiles(t *testing.T) {
svc, _ := newBackupTestService(t)
manual, err := svc.Create(t.Context())
if err != nil {
t.Fatal(err)
}
automatic, err := svc.CreateAutomatic(t.Context())
if err != nil {
t.Fatal(err)
}
if manual.Filename == automatic.Filename || manual.Automatic || !automatic.Automatic {
t.Fatalf("invalid backup identities: manual=%#v automatic=%#v", manual, automatic)
}
items, err := svc.List()
if err != nil || len(items) != 2 {
t.Fatalf("completed backup list: items=%#v err=%v", items, err)
}
for _, item := range items {
info, err := os.Stat(item.Path)
if err != nil {
t.Fatal(err)
}
if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
t.Fatalf("backup permissions are not private: %s err=%v", item.Path, err)
}
}
}
+89 -10
View File
@@ -1,26 +1,34 @@
// 下载直链进程级缓存。
//
// 115 的 /open/ufile/downurl 换取结果在一段时间内有效,且换取请求是
// WAF 风控重点关注的密集接口。缓存按 pickcode 存直链、避免对同一文件
// 反复换取;下载失败(http 403/404 等)时由调用方主动清除对应条目,
// 下一轮重试会重新换取链接。
// 115 的 /open/ufile/downurl 换取结果带 `t=` 过期时间戳(实测为「换取时刻 +
// 1800 秒」,即 30 分钟),且换取请求是 WAF 风控重点关注的密集接口。缓存按
// pickcode 存直链、避免对同一文件反复换取;下载失败(http 403/404 等)时由
// 调用方主动清除对应条目,下一轮重试会重新换取链接。
package cloud115
import (
"net/url"
"strconv"
"strings"
"sync"
"time"
)
// downloadURLCacheTTL 直链缓存时长。115 直链一般有效 1 小时左右,
// 取 45 分钟留出余量。QMediaSync 同类缓存使用 50 分钟。
const downloadURLCacheTTL = 45 * time.Minute
// downloadURLCacheMaxTTL 直链缓存上限:仅在解析不出直链自带过期时间(`t=`)
// 时使用。115 直链实际有效 30 分钟,这里取 45 分钟只是兜底上限,正常情况下
// 由 linkExpirySafety 提前失效,避免把已经过期的直链发给客户端。
const downloadURLCacheMaxTTL = 45 * time.Minute
// linkExpirySafety 提前量:直链自身的过期时间戳到达前就停止使用,留出客户端
// 建连与传输的余量,防止出现「服务端认为还有效、CDN 已经 403」的窗口。
const linkExpirySafety = 2 * time.Minute
// maxCachedURLs 超过该数量时顺带清理过期条目,防止 map 无限膨胀。
const maxCachedURLs = 10000
type urlCacheEntry struct {
url string
createdAt time.Time
expiresAt time.Time
}
@@ -37,6 +45,43 @@ func urlCacheKey(pickCode, ua string) string {
return pickCode + "@" + ua
}
// LinkExpiry 解析 115 直链自带的过期时间戳(`t=` 参数,单位秒)。
// 解析不出时返回零值,调用方按兜底 TTL 处理。
func LinkExpiry(rawURL string) time.Time {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || u == nil {
return time.Time{}
}
raw := strings.TrimSpace(u.Query().Get("t"))
if raw == "" {
return time.Time{}
}
sec, err := strconv.ParseInt(raw, 10, 64)
if err != nil || sec <= 0 {
return time.Time{}
}
return time.Unix(sec, 0)
}
// cacheExpiry 计算写入缓存时应使用的过期时刻:跟随直链自身的 `t=` 并提前
// linkExpirySafety,解析不出时退回 downloadURLCacheMaxTTL。返回零值表示这条
// 直链已经过期,不应写入缓存。
func cacheExpiry(rawURL string, now time.Time) time.Time {
maxExpiry := now.Add(downloadURLCacheMaxTTL)
linkExpiry := LinkExpiry(rawURL)
if linkExpiry.IsZero() {
return maxExpiry
}
expiry := linkExpiry.Add(-linkExpirySafety)
if !expiry.After(now) {
return time.Time{}
}
if expiry.After(maxExpiry) {
return maxExpiry
}
return expiry
}
// GetDownloadURLCache 返回未过期的缓存直链;不存在或已过期返回空串。
func GetDownloadURLCache(pickCode string, uas ...string) string {
if pickCode == "" {
@@ -59,11 +104,17 @@ func GetDownloadURLCache(pickCode string, uas ...string) string {
return entry.url
}
// SetDownloadURLCache 写入直链缓存。
// SetDownloadURLCache 写入直链缓存。过期时刻跟随直链自身的 `t=`,已经过期的
// 直链直接丢弃,避免把死链缓存下来。
func SetDownloadURLCache(pickCode string, url string, uas ...string) {
if pickCode == "" || url == "" {
return
}
now := time.Now()
expiresAt := cacheExpiry(url, now)
if expiresAt.IsZero() {
return
}
ua := ""
if len(uas) > 0 {
ua = uas[0]
@@ -71,7 +122,6 @@ func SetDownloadURLCache(pickCode string, url string, uas ...string) {
urlCacheMu.Lock()
defer urlCacheMu.Unlock()
if len(urlCache) >= maxCachedURLs {
now := time.Now()
for k, e := range urlCache {
if now.After(e.expiresAt) {
delete(urlCache, k)
@@ -79,7 +129,7 @@ func SetDownloadURLCache(pickCode string, url string, uas ...string) {
}
}
key := urlCacheKey(pickCode, ua)
urlCache[key] = urlCacheEntry{url: url, expiresAt: time.Now().Add(downloadURLCacheTTL)}
urlCache[key] = urlCacheEntry{url: url, createdAt: now, expiresAt: expiresAt}
}
// ClearDownloadURLCache 删除指定 pickcode 的所有缓存(下载得到非 2xx 时调用)。
@@ -95,3 +145,32 @@ func ClearDownloadURLCache(pickCode string) {
}
}
}
// ClearDownloadURLCacheIfOlderThan 删除指定 pickcode 下「已经存在超过 minAge」
// 的缓存条目,返回是否删除了至少一条。
//
// 客户端上报「直链被 CDN 拒绝」时用它强制换链:既让下一次解析拿到新地址,
// 又不会让同一秒内的重试风暴把 115 换链接口打出突发。
func ClearDownloadURLCacheIfOlderThan(pickCode string, minAge time.Duration) bool {
if pickCode == "" {
return false
}
if minAge < 0 {
minAge = 0
}
now := time.Now()
removed := false
urlCacheMu.Lock()
defer urlCacheMu.Unlock()
for k, e := range urlCache {
if k != pickCode && !strings.HasPrefix(k, pickCode+"@") {
continue
}
if now.Sub(e.createdAt) < minAge {
continue
}
delete(urlCache, k)
removed = true
}
return removed
}
@@ -1,6 +1,7 @@
package cloud115
import (
"fmt"
"testing"
"time"
)
@@ -29,3 +30,72 @@ func TestDownloadURLCache(t *testing.T) {
t.Fatalf("cleared entry should be a miss, got %q", got)
}
}
// 115 直链自带 `t=` 过期时间戳(实测为换取时刻 + 1800 秒)。缓存必须跟随它,
// 否则会出现「服务端认为还有效、CDN 已经 403」的窗口:直链被缓存到 45 分钟,
// 但第 30 分钟起客户端就必然拿到 403。
func TestDownloadURLCacheExpiryFollowsLinkTimestamp(t *testing.T) {
now := time.Now()
link := fmt.Sprintf("https://cdn/1/a.mkv?t=%d&u=1&k=abc", now.Add(30*time.Minute).Unix())
got := cacheExpiry(link, now)
want := now.Add(30*time.Minute - linkExpirySafety)
if diff := got.Sub(want); diff > time.Second || diff < -time.Second {
t.Fatalf("cacheExpiry = %v, want %v", got, want)
}
// 解析不出 t= 时退回兜底 TTL,保持旧行为。
fallback := cacheExpiry("https://cdn/1/a.mkv", now)
if diff := fallback.Sub(now.Add(downloadURLCacheMaxTTL)); diff > time.Second || diff < -time.Second {
t.Fatalf("fallback cacheExpiry = %v, want %v", fallback, now.Add(downloadURLCacheMaxTTL))
}
// 直链本身已经过期(或只剩不足提前量的寿命)时不进缓存。
if got := cacheExpiry(fmt.Sprintf("https://cdn/1/a.mkv?t=%d", now.Add(-time.Minute).Unix()), now); !got.IsZero() {
t.Fatalf("expired link must not be cached, got expiry %v", got)
}
if got := cacheExpiry(fmt.Sprintf("https://cdn/1/a.mkv?t=%d", now.Add(time.Minute).Unix()), now); !got.IsZero() {
t.Fatalf("link expiring inside the safety margin must not be cached, got %v", got)
}
}
// 客户端上报「直链被 CDN 拒绝」时按 pickcode 清缓存,但要挡住刚写入的条目,
// 避免重试风暴把 115 换链接口打出突发。
func TestClearDownloadURLCacheIfOlderThan(t *testing.T) {
urlCacheMu.Lock()
urlCache = map[string]urlCacheEntry{}
urlCacheMu.Unlock()
t.Cleanup(func() {
urlCacheMu.Lock()
urlCache = map[string]urlCacheEntry{}
urlCacheMu.Unlock()
})
future := time.Now().Add(30 * time.Minute)
link := fmt.Sprintf("https://cdn/1/a.mkv?t=%d", future.Unix())
SetDownloadURLCache("pick-fresh", link, "ua-a")
if ClearDownloadURLCacheIfOlderThan("pick-fresh", time.Minute) {
t.Fatal("fresh entry must survive the min-age guard")
}
if got := GetDownloadURLCache("pick-fresh", "ua-a"); got != link {
t.Fatalf("fresh entry lost, got %q", got)
}
SetDownloadURLCache("pick-old", link, "ua-a")
urlCacheMu.Lock()
entry := urlCache[urlCacheKey("pick-old", "ua-a")]
entry.createdAt = time.Now().Add(-10 * time.Minute)
urlCache[urlCacheKey("pick-old", "ua-a")] = entry
urlCacheMu.Unlock()
if !ClearDownloadURLCacheIfOlderThan("pick-old", time.Minute) {
t.Fatal("stale entry should be cleared")
}
if got := GetDownloadURLCache("pick-old", "ua-a"); got != "" {
t.Fatalf("stale entry should be gone, got %q", got)
}
// 无关 pickcode 不受影响。
if got := GetDownloadURLCache("pick-fresh", "ua-a"); got != link {
t.Fatalf("unrelated pickcode lost, got %q", got)
}
}
+67 -1
View File
@@ -40,6 +40,23 @@ import (
// embyRemoteHTTPTimeout 远程 Emby 常规 API 请求超时(流式代理不在此列)。
const embyRemoteHTTPTimeout = 15 * time.Second
// embyRemoteViewsBudget 是网页端聚合远程媒体库时的总时间预算。
//
// 上游 Emby 整体不可用时(典型表现:Cloudflare 522,连接卡住近 20 秒),
// 首页 /api/libraries 会被同步阻塞,而前端 axios 30 秒就放弃,于是一个挂掉的
// 远端会把整个首页拖成加载失败。这里给聚合调用一个硬预算:超时即降级为
// 「只返回本地库」,把首屏确定性地控制在几秒内。
var embyRemoteViewsBudget = 5 * time.Second
// embyRemoteViewsFailureTTL 是远程 Views 拉取失败后的负缓存时长。
//
// 失败结果也缓存一小段时间,避免每个请求都重新等满预算(首页每次刷新、
// 每个并发客户端都会各打一次)。时间很短,远端恢复后最多 30 秒即可自动重试。
var embyRemoteViewsFailureTTL = 30 * time.Second
// errRemoteViewsRecentlyFailed 表示远程 Emby 刚失败过,当前处于负缓存窗口。
var errRemoteViewsRecentlyFailed = errors.New("远程 Emby 暂时不可用(近期请求失败,稍后自动重试)")
// embyRemoteUA 桌面浏览器 UA:远程 Emby 前方若有 Cloudflare/WAF 会拦截
// Go-http-client 等非浏览器 UA(403 error code: 1010),必须伪装浏览器。
const embyRemoteUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
@@ -422,6 +439,22 @@ func (r *EmbyRemoteService) DeleteMount(ctx context.Context, id string) error {
return err
}
// SetMountsEnabled 批量启用/停用某账号下的全部挂载。
//
// 账号整体不可用时(远端宕机、线路全挂),管理员可以一键把所有挂载从媒体库
// 中摘掉:网页端 /api/libraries 不再为该账号发起任何远程请求,首页恢复秒开;
// 远端恢复后再一键启用即可,挂载配置和播放设置都不丢。
func (r *EmbyRemoteService) SetMountsEnabled(ctx context.Context, accountID string, enabled bool) (int64, error) {
affected, err := r.repo.EmbyMount.SetEnabledByAccountID(ctx, accountID, enabled)
if err != nil {
return 0, err
}
// 启停会改变可见库集合,顺手清掉远程元数据缓存(含 Views 失败负缓存),
// 让下一次请求立刻按新状态重新探测,而不是继续吃 30 秒的负缓存。
r.invalidateRemoteMediaCache(ctx)
return affected, nil
}
// ReorderMounts 批量重排挂载媒体库顺序。
func (r *EmbyRemoteService) ReorderMounts(ctx context.Context, ids []string) error {
if len(ids) == 0 {
@@ -822,7 +855,13 @@ func (r *EmbyRemoteService) TestConnection(ctx context.Context, acct *model.Strm
return err
}
var out json.RawMessage
return r.doGet(ctx, acct, cfg, "/System/Info", nil, &out)
if err := r.doGet(ctx, acct, cfg, "/System/Info", nil, &out); err != nil {
return err
}
// 连通性恢复:清掉 Views 失败负缓存与旧元数据,让下一次媒体库列表请求
// 立即重新探测,而不是继续在负缓存窗口里显示「没有远程库」。
r.invalidateRemoteMediaCache(ctx)
return nil
}
// ProxyPlayOf 返回账号是否配置了播放代理(供账号列表/编辑回显)。
@@ -837,7 +876,34 @@ func (r *EmbyRemoteService) ProxyPlayOf(acct *model.StrmAccount) (bool, error) {
// ─── 元数据 / 目录聚合 ─────────────────────────────────────────────────────────
// RemoteViews 拉取远程媒体库(View)列表,返回远程原始 view map(未重写)。
//
// 失败时写入短时负缓存:远程整站不可用时,避免每次首页刷新都重新等满超时。
func (r *EmbyRemoteService) RemoteViews(ctx context.Context, acct *model.StrmAccount) ([]map[string]any, error) {
if acct == nil {
return nil, errors.New("缺少远程 Emby 账号")
}
failKey := r.remoteCacheKey("views-fail", acct.ID)
var failed bool
if r.cache != nil && r.cache.GetJSON(ctx, failKey, &failed) && failed {
return nil, errRemoteViewsRecentlyFailed
}
views, err := r.remoteViewsUncached(ctx, acct)
if err != nil {
// 客户端主动断开(切换页面 / 关标签)不算上游故障,不能让它把
// 负缓存窗口写进去,否则正常用户会被别人的一次取消拖慢 30 秒。
if r.cache != nil && !errors.Is(err, context.Canceled) {
r.cache.SetJSON(ctx, failKey, true, embyRemoteViewsFailureTTL)
}
return nil, err
}
if r.cache != nil {
r.cache.SetJSON(ctx, failKey, false, embyRemoteViewsFailureTTL)
}
return views, nil
}
// remoteViewsUncached 是 RemoteViews 的实际拉取逻辑(含正常结果缓存)。
func (r *EmbyRemoteService) remoteViewsUncached(ctx context.Context, acct *model.StrmAccount) ([]map[string]any, error) {
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, err
@@ -0,0 +1,155 @@
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// TestRemoteLibrariesFastFailAndNegativeCache 覆盖首页可用的底线保证:
// 上游 Emby 整体挂起时,聚合远程库必须在一个很短的预算内失败并降级为
// 「没有远程库」,并且在负缓存窗口内不再重复打上游。
func TestRemoteLibrariesFastFailAndNegativeCache(t *testing.T) {
oldBudget, oldTTL := embyRemoteViewsBudget, embyRemoteViewsFailureTTL
embyRemoteViewsBudget = 200 * time.Millisecond
embyRemoteViewsFailureTTL = time.Minute
t.Cleanup(func() {
embyRemoteViewsBudget = oldBudget
embyRemoteViewsFailureTTL = oldTTL
})
release := make(chan struct{})
var hits int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
// 模拟 Cloudflare 522 前近 20 秒的挂起:请求一直不返回。
select {
case <-release:
case <-time.After(10 * time.Second):
}
}))
defer upstream.Close()
defer close(release)
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
remote := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop())).
SetRuntimeCache(NewRuntimeCacheService(nil, zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": upstream.URL,
"api_key": "test-api-key",
"remote_user_id": "uid-1",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-hang"},
Name: "挂起的远端",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-hang"},
AccountID: acct.ID,
RemoteViewID: "view-1",
RemoteViewName: "卡住的库",
CollectionType: "movies",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
start := time.Now()
views, err := remote.RemoteLibraries(t.Context())
elapsed := time.Since(start)
if err != nil {
t.Fatalf("RemoteLibraries should degrade instead of failing: %v", err)
}
if len(views) != 0 {
t.Fatalf("expected no remote libraries while upstream hangs, got %d", len(views))
}
if elapsed > 2*time.Second {
t.Fatalf("RemoteLibraries took %v, want within the short budget", elapsed)
}
if atomic.LoadInt32(&hits) == 0 {
t.Fatal("expected upstream to be probed once")
}
// 负缓存窗口内:不再打上游,且立即返回空结果。
before := atomic.LoadInt32(&hits)
start = time.Now()
views, err = remote.RemoteLibraries(t.Context())
elapsed = time.Since(start)
if err != nil {
t.Fatalf("second RemoteLibraries should not fail: %v", err)
}
if len(views) != 0 {
t.Fatalf("expected no remote libraries from negative cache, got %d", len(views))
}
if elapsed > time.Second {
t.Fatalf("negative cache should answer immediately, took %v", elapsed)
}
if got := atomic.LoadInt32(&hits); got != before {
t.Fatalf("negative cache should skip upstream, hits went %d -> %d", before, got)
}
}
// TestRemoteViewsNotNegativeCachedOnClientCancel 保证客户端主动断开不会污染
// 负缓存:否则一个用户切页就会让所有人在 30 秒内看不到远程库。
func TestRemoteViewsNotNegativeCachedOnClientCancel(t *testing.T) {
release := make(chan struct{})
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-release:
case <-time.After(10 * time.Second):
}
}))
defer upstream.Close()
defer close(release)
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
cache := NewRuntimeCacheService(nil, zap.NewNop())
remote := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop())).
SetRuntimeCache(cache)
rawConfig, _ := json.Marshal(map[string]string{
"url": upstream.URL,
"api_key": "test-api-key",
"remote_user_id": "uid-1",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-cancel"},
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
ctx, cancel := context.WithCancel(t.Context())
cancel()
if _, err := remote.RemoteViews(ctx, acct); err == nil {
t.Fatal("expected canceled context to fail")
}
var failed bool
if cache.GetJSON(t.Context(), remote.remoteCacheKey("views-fail", acct.ID), &failed) && failed {
t.Fatal("client cancel must not write the negative cache")
}
}
+6
View File
@@ -35,6 +35,12 @@ type RemoteLibraryView struct {
// RemoteLibraries 把所有启用挂载的远程媒体库映射为网页媒体库列表
// (只有显式挂载的库才出现在本项目媒体库中)。
func (r *EmbyRemoteService) RemoteLibraries(ctx context.Context) ([]RemoteLibraryView, error) {
// 远程库只是「追加」到本地库列表上,绝不允许它拖住整页首屏:给整段
// 聚合(多账号 × 多线路故障转移)一个总预算,超时后各账号按失败处理,
// 调用方随即降级为只返回本地库。
ctx, cancel := context.WithTimeout(ctx, embyRemoteViewsBudget)
defer cancel()
mounts, err := r.ListMounts(ctx)
if err != nil || len(mounts) == 0 {
return nil, err
+6 -3
View File
@@ -22,8 +22,9 @@ import (
const (
// IntroDBBaseURL is the public read endpoint. Overridable on the service
// for tests and for pointing at a mirror.
IntroDBBaseURL = "https://api.theintrodb.org/v3"
// for tests and for pointing at a mirror. v1 已删除,v2/v3 冻结在只读的
// GET /media 上并被官方标记为弃用,所以这里直接跟 v4。
IntroDBBaseURL = "https://api.theintrodb.org/v4"
// IntroDBSource tags rows that came from this provider.
IntroDBSource = "theintrodb"
@@ -92,8 +93,10 @@ type introDBRange struct {
EndMs *int64 `json:"end_ms"`
}
// introDBResponse 对应 v4 的 MediaResponse。v4 以 TVDB 为身份,响应只带
// tvdb_id —— tmdb_id 字段已被官方移除,需要 tmdb_id 只能从请求侧自己记。
type introDBResponse struct {
TMDbID int `json:"tmdb_id"`
TVDBID int `json:"tvdb_id"`
Type string `json:"type"`
Intro []introDBRange `json:"intro"`
Recap []introDBRange `json:"recap"`
+9 -8
View File
@@ -11,11 +11,12 @@ import (
"go.uber.org/zap"
)
// 这两段响应是从 api.theintrodb.org/v3/media 实测抓下来的原文,
// 用来锁住 null 语义:start_ms: null = 从片头开始,end_ms: null = 一直到片尾。
// 这两段响应是从 api.theintrodb.org/v4/media 实测抓下来的原文(v4 以 TVDB 为身份,
// 响应里只有 tvdb_id),用来锁住 null 语义:start_ms: null = 从片头开始,
// end_ms: null = 一直到片尾。
const (
introDBTVPayload = `{"tmdb_id":1396,"type":"tv","season":1,"episode":1,"intro":[{"start_ms":228664,"end_ms":246143}],"credits":[{"start_ms":3431000,"end_ms":null}]}`
introDBMoviePayload = `{"tmdb_id":27205,"type":"movie","intro":[{"start_ms":null,"end_ms":38000}]}`
introDBTVPayload = `{"tvdb_id":81189,"type":"tv","season":1,"episode":1,"intro":[{"start_ms":228664,"end_ms":246143}],"credits":[{"start_ms":3431000,"end_ms":null}]}`
introDBMoviePayload = `{"tvdb_id":113,"type":"movie","intro":[{"start_ms":null,"end_ms":38000}]}`
)
func TestParseIntroDBResponseResolvesNullBounds(t *testing.T) {
@@ -48,7 +49,7 @@ func TestParseIntroDBResponseResolvesNullBounds(t *testing.T) {
}
func TestParseIntroDBResponseDropsEmptyRanges(t *testing.T) {
body := `{"tmdb_id":1,"type":"movie",
body := `{"tvdb_id":1,"type":"movie",
"intro":[{"start_ms":5000,"end_ms":5000},{"start_ms":9000,"end_ms":8000},{"start_ms":1000,"end_ms":2000}],
"recap":[],"credits":[],"preview":[]}`
spans, err := parseIntroDBResponse([]byte(body))
@@ -62,7 +63,7 @@ func TestParseIntroDBResponseDropsEmptyRanges(t *testing.T) {
}
func TestParseIntroDBResponseOrdersByType(t *testing.T) {
body := `{"tmdb_id":1,"type":"tv","credits":[{"start_ms":900,"end_ms":1000}],
body := `{"tvdb_id":1,"type":"tv","credits":[{"start_ms":900,"end_ms":1000}],
"intro":[{"start_ms":100,"end_ms":200}],"recap":[{"start_ms":50,"end_ms":60}]}`
spans, err := parseIntroDBResponse([]byte(body))
if err != nil {
@@ -82,12 +83,12 @@ func TestParseIntroDBResponseOrdersByType(t *testing.T) {
func TestIntroDBMediaURLOnlyAddsSeasonEpisodeForTV(t *testing.T) {
svc := NewIntroDBService(zap.NewNop())
if got, want := svc.mediaURL(1396, 1, 1),
"https://api.theintrodb.org/v3/media?tmdb_id=1396&season=1&episode=1"; got != want {
"https://api.theintrodb.org/v4/media?tmdb_id=1396&season=1&episode=1"; got != want {
t.Fatalf("tv url = %q, want %q", got, want)
}
// 电影(season/episode 为 0)不能带季集参数,否则会被当成剧集查不到。
if got, want := svc.mediaURL(27205, 0, 0),
"https://api.theintrodb.org/v3/media?tmdb_id=27205"; got != want {
"https://api.theintrodb.org/v4/media?tmdb_id=27205"; got != want {
t.Fatalf("movie url = %q, want %q", got, want)
}
}
@@ -184,6 +184,85 @@ func TestReadAdultMetadataDerivesDMMPosterFromRemoteFanart(t *testing.T) {
}
}
func TestReadAdultArtworkIgnoresLoosePosterInLibraryRoot(t *testing.T) {
root := t.TempDir()
// 云下载这类库把不同影片平铺在同一个根目录里,根目录中残留的松散海报
// 属于别的影片,不能被抓来当作当前影片的封面。
if err := os.WriteFile(filepath.Join(root, "AAA-001-poster.jpg"), []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "AAA-001-fanart.jpg"), []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
mediaPath := filepath.Join(root, "BBB-002.mp4")
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadLocalMetadata(mediaPath, root, false)
if err != nil {
t.Fatal(err)
}
if got != nil && (got.PosterURL != "" || got.BackdropURL != "") {
t.Fatalf("library-root artwork must not be inherited: poster=%q backdrop=%q", got.PosterURL, got.BackdropURL)
}
}
func TestReadAdultArtworkIgnoresLoosePosterInSharedFolder(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "shared")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
for _, name := range []string{"AAA-001.mp4", "BBB-002.mp4"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(dir, "AAA-001-poster.jpg"), []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "AAA-001-fanart.jpg"), []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadLocalMetadata(filepath.Join(dir, "BBB-002.mp4"), root, false)
if err != nil {
t.Fatal(err)
}
if got != nil && (got.PosterURL != "" || got.BackdropURL != "") {
t.Fatalf("sibling artwork must not be inherited in a shared folder: poster=%q backdrop=%q", got.PosterURL, got.BackdropURL)
}
}
func TestReadAdultArtworkKeepsLooseFallbackInItemFolder(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "BBB-002")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
mediaPath := filepath.Join(dir, "video.mp4")
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
poster := filepath.Join(dir, "japanese-title-cover.jpg")
fanart := filepath.Join(dir, "japanese-title-fanart.jpg")
if err := os.WriteFile(poster, []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(fanart, []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadLocalMetadata(mediaPath, root, false)
if err != nil {
t.Fatal(err)
}
if got == nil || got.PosterURL != poster || got.BackdropURL != fanart {
t.Fatalf("single-item folder should keep loose artwork fallback: %+v", got)
}
}
func TestReadAdultArtworkByCodeWithoutNFO(t *testing.T) {
root := t.TempDir()
mediaPath := filepath.Join(root, "SSIS-001-CD1.mp4")
+47 -5
View File
@@ -11,23 +11,23 @@ import (
"strings"
)
func metadataFromArtwork(mediaPath, showBaseDir string) *LocalMetadata {
func metadataFromArtwork(mediaPath, showBaseDir, libraryRoot string) *LocalMetadata {
meta := &LocalMetadata{}
mergeArtworkMetadata(meta, mediaPath, showBaseDir)
mergeArtworkMetadata(meta, mediaPath, showBaseDir, libraryRoot)
if meta.PosterURL == "" && meta.BackdropURL == "" {
return nil
}
return meta
}
func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) {
func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir, libraryRoot string) {
if meta == nil {
return
}
mediaDir := filepath.Dir(mediaPath)
if localPoster := firstLocalPoster(mediaPath, showBaseDir); localPoster != "" {
meta.PosterURL = localPoster
} else if meta.PosterURL == "" {
} else if meta.PosterURL == "" && looseArtworkFallbackAllowed(mediaDir, libraryRoot) {
meta.PosterURL = firstAdultLooseImage(mediaDir, "poster")
}
dirs := []string{mediaDir, showBaseDir}
@@ -39,7 +39,7 @@ func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) {
meta.BackdropURL = img
break
}
if meta.BackdropURL == "" {
if meta.BackdropURL == "" && looseArtworkFallbackAllowed(dir, libraryRoot) {
meta.BackdropURL = firstAdultLooseImage(dir, "backdrop")
}
}
@@ -245,6 +245,48 @@ func firstExistingPosterImage(dir string, names ...string) string {
return ""
}
// looseArtworkFallbackAllowed reports whether an unmatched image sitting in dir
// can be attributed to mediaPath. Such an image carries no filename relation to
// a specific video, so it is only trustworthy when the folder holds a single
// media file and that folder is not the library root. Download tools routinely
// drop unrelated titles flat into one directory (a 115 cloud-download library,
// for example); claiming the first "*-poster.jpg" there made every item show the
// same artwork. Mirrors the root-level guard findMovieNFO already applies to
// loose NFO files.
func looseArtworkFallbackAllowed(dir, libraryRoot string) bool {
if dir == "" {
return false
}
if libraryRoot != "" && samePath(dir, libraryRoot) {
return false
}
return countMediaFilesInDir(dir) <= 1
}
// countMediaFilesInDir counts the video files directly inside dir, stopping once
// more than one is found. It walks the real directory rather than reusing the
// loose-image glob because a shared folder may contain no images at all.
func countMediaFilesInDir(dir string) int {
entries, err := os.ReadDir(dir)
if err != nil {
return 0
}
count := 0
for _, entry := range entries {
if entry.IsDir() {
continue
}
if _, ok := videoExtensions[strings.ToLower(filepath.Ext(entry.Name()))]; !ok {
continue
}
count++
if count > 1 {
return count
}
}
return count
}
func firstAdultLooseImage(dir, kind string) string {
if dir == "" {
return ""
+4 -4
View File
@@ -17,12 +17,12 @@ func ReadLocalMetadata(mediaPath, libraryRoot string, seriesLike bool) (*LocalMe
doc, path, err := findMovieNFO(mediaPath, libraryRoot)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return metadataFromArtwork(mediaPath, ""), nil
return metadataFromArtwork(mediaPath, "", libraryRoot), nil
}
return nil, err
}
meta := metadataFromDoc(doc, filepath.Dir(path), false)
mergeArtworkMetadata(meta, mediaPath, filepath.Dir(path))
mergeArtworkMetadata(meta, mediaPath, filepath.Dir(path), libraryRoot)
// A sidecar written while resolution tokens were misread as SxxExx must not
// reintroduce the bogus season/episode on rescan.
dropResolutionArtifactEpisodeIdentity(meta, mediaPath)
@@ -115,9 +115,9 @@ func readSeriesMetadata(mediaPath, libraryRoot string) (*LocalMetadata, error) {
}
}
if meta == nil {
meta = metadataFromArtwork(mediaPath, showBaseDir)
meta = metadataFromArtwork(mediaPath, showBaseDir, libraryRoot)
} else {
mergeArtworkMetadata(meta, mediaPath, showBaseDir)
mergeArtworkMetadata(meta, mediaPath, showBaseDir, libraryRoot)
}
// A sidecar written while resolution tokens were misread as SxxExx must not
// reintroduce the bogus season/episode on rescan.
+8 -7
View File
@@ -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 {
+31 -16
View File
@@ -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
}
+66
View File
@@ -2,17 +2,71 @@ package service
import (
"context"
"fmt"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
type MediaQualityItem struct {
Issue string `json:"issue"`
Media model.Media `json:"media"`
}
func (s *MediaService) QualityIssues(ctx context.Context, issue string, page, pageSize int) ([]MediaQualityItem, int64, error) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 50
}
if pageSize > 100 {
pageSize = 100
}
q := s.repo.DB.WithContext(ctx).Model(&model.Media{})
switch issue {
case "missing":
q = q.Where("availability_state = ?", "missing")
case "unmatched":
q = q.Where("scrape_status IN ? OR scrape_status = ''", []string{"pending", "no_match"})
case "poster":
q = q.Where("poster_url IS NULL OR TRIM(poster_url) = ''")
default:
return nil, 0, fmt.Errorf("unknown quality issue: %s", issue)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []model.Media
if err := q.Order("updated_at DESC, id ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&rows).Error; err != nil {
return nil, 0, err
}
s.attachLibraryMetadata(ctx, rows)
items := make([]MediaQualityItem, 0, len(rows))
for _, media := range rows {
items = append(items, MediaQualityItem{Issue: issue, Media: media})
}
return items, total, nil
}
type MediaSearchFilter struct {
LibraryID string
YearMin int
YearMax int
Genres []string
}
// SearchMedia performs a simple LIKE search across titles.
func (s *MediaService) SearchMedia(ctx context.Context, query string, limit int) ([]model.Media, error) {
return s.SearchMediaVisible(ctx, query, limit, MediaVisibility{IncludeNSFW: true})
}
func (s *MediaService) SearchMediaVisible(ctx context.Context, query string, limit int, visibility MediaVisibility) ([]model.Media, error) {
return s.SearchMediaVisibleFiltered(ctx, query, limit, visibility, MediaSearchFilter{})
}
func (s *MediaService) SearchMediaVisibleFiltered(ctx context.Context, query string, limit int, visibility MediaVisibility, filter MediaSearchFilter) ([]model.Media, error) {
if limit <= 0 {
limit = 50
} else if limit > maxMediaSearchLimit {
@@ -23,6 +77,10 @@ func (s *MediaService) SearchMediaVisible(ctx context.Context, query string, lim
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
LibraryID: filter.LibraryID,
YearMin: filter.YearMin,
YearMax: filter.YearMax,
Genres: filter.Genres,
})
if err != nil {
return nil, err
@@ -45,6 +103,10 @@ func (s *MediaService) SearchMediaVisibleGrouped(ctx context.Context, query stri
}
func (s *MediaService) SearchMediaVisiblePage(ctx context.Context, query string, page, pageSize int, visibility MediaVisibility) ([]model.Media, int64, error) {
return s.SearchMediaVisiblePageFiltered(ctx, query, page, pageSize, visibility, MediaSearchFilter{})
}
func (s *MediaService) SearchMediaVisiblePageFiltered(ctx context.Context, query string, page, pageSize int, visibility MediaVisibility, filter MediaSearchFilter) ([]model.Media, int64, error) {
if pageSize <= 0 {
pageSize = 50
}
@@ -59,6 +121,10 @@ func (s *MediaService) SearchMediaVisiblePage(ctx context.Context, query string,
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
LibraryID: filter.LibraryID,
YearMin: filter.YearMin,
YearMax: filter.YearMax,
Genres: filter.Genres,
})
if err != nil {
return nil, 0, err
+77
View File
@@ -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
}
+141
View File
@@ -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
View File
@@ -14,6 +14,7 @@ import (
"errors"
"strings"
"sync"
"time"
"go.uber.org/zap"
@@ -144,32 +145,34 @@ type localMediaProbeTask struct {
}
type existingLocalMedia struct {
LibraryRootID string
RelativePath string
Title string
OriginalName string
EpisodeTitle string
SizeBytes int64
DurationSec int
Width int
Height int
VideoCodec string
AudioCodec string
Container string
STRMURL string
FileID string
PosterURL string
BackdropURL string
Overview string
Year int
ReleaseDate string
Rating float32
TMDbID int
BangumiID int
DoubanID string
TheTVDBID string
SeasonNum int
EpisodeNum int
AvailabilityState string
MissingSince *time.Time
LibraryRootID string
RelativePath string
Title string
OriginalName string
EpisodeTitle string
SizeBytes int64
DurationSec int
Width int
Height int
VideoCodec string
AudioCodec string
Container string
STRMURL string
FileID string
PosterURL string
BackdropURL string
Overview string
Year int
ReleaseDate string
Rating float32
TMDbID int
BangumiID int
DoubanID string
TheTVDBID string
SeasonNum int
EpisodeNum int
// EpisodeFraction 是集号的小数部分(S01E11.5 → 0.5);变化同样要触发刷新,
// 否则已有行上的「半集」标记不会被写回。
EpisodeFraction float64
+3 -1
View File
@@ -33,7 +33,7 @@ func (s *ScannerService) existingLocalMediaSnapshotForRoot(ctx context.Context,
query = query.Where("(library_root_id = ? OR library_root_id = '' OR library_root_id IS NULL)", rootID)
}
rows, err := query.
Select("path", "library_root_id", "relative_path", "title", "original_name", "episode_title", "size_bytes", "duration_sec", "width", "height", "video_codec", "audio_codec", "container", "strm_url", "file_id", "poster_url", "backdrop_url", "overview", "year", "release_date", "rating", "tm_db_id", "bangumi_id", "douban_id", "thetvdb_id", "season_num", "episode_num", "episode_fraction", "genres", "countries", "languages", "nsfw", "scrape_status").
Select("path", "library_root_id", "relative_path", "title", "original_name", "episode_title", "size_bytes", "duration_sec", "width", "height", "video_codec", "audio_codec", "container", "strm_url", "file_id", "poster_url", "backdrop_url", "overview", "year", "release_date", "rating", "tm_db_id", "bangumi_id", "douban_id", "thetvdb_id", "season_num", "episode_num", "episode_fraction", "genres", "countries", "languages", "nsfw", "scrape_status", "availability_state", "missing_since").
Rows()
if err != nil {
return nil, err
@@ -80,6 +80,8 @@ func (s *ScannerService) existingLocalMediaSnapshotForRoot(ctx context.Context,
&row.Languages,
&row.NSFW,
&row.ScrapeStatus,
&row.AvailabilityState,
&row.MissingSince,
); err != nil {
return nil, err
}
+10 -3
View File
@@ -548,7 +548,7 @@ func TestScanLibraryMapsPersistedHostLibraryPath(t *testing.T) {
}
}
func TestRemovePathDeletesVanishedMedia(t *testing.T) {
func TestRemovePathRetainsVanishedMedia(t *testing.T) {
sc, repos := newScannerTestEnv(t)
cache := NewRuntimeCacheService(&config.Config{}, zap.NewNop())
sc.SetRuntimeCache(cache)
@@ -589,8 +589,15 @@ func TestRemovePathDeletesVanishedMedia(t *testing.T) {
if removed != 1 {
t.Fatalf("removed = %d, want 1", removed)
}
if countMedia(t, repos) != 0 {
t.Fatal("expected 0 media after removal")
if countMedia(t, repos) != 1 {
t.Fatal("missing media metadata must be retained")
}
var missing model.Media
if err := repos.DB.Where("path = ?", file).First(&missing).Error; err != nil {
t.Fatal(err)
}
if missing.AvailabilityState != "missing" || missing.MissingSince == nil {
t.Fatalf("missing state not recorded: %#v", missing)
}
if cache.GetJSON(t.Context(), "media:list:test", &cached) {
t.Fatal("vanished media removal should invalidate media cache")
+10 -2
View File
@@ -67,8 +67,16 @@ func TestScanLibraryScansMultipleRootsAndPrunesPerRoot(t *testing.T) {
if err := db.Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Path != fileB {
t.Fatalf("remaining rows = %#v, want offline root media preserved", rows)
if len(rows) != 2 {
t.Fatalf("remaining rows = %#v, want both media records preserved", rows)
}
for _, row := range rows {
if row.Path == fileA && row.AvailabilityState != "missing" {
t.Fatalf("vanished file was not marked missing: %#v", row)
}
if row.Path == fileB && row.AvailabilityState != "available" {
t.Fatalf("offline root status was changed: %#v", row)
}
}
}
+1 -1
View File
@@ -122,7 +122,7 @@ func (s *ScannerService) localMediaScanState(in localMediaScanStateInput) (bool,
}
existing, exists := in.existingMedia[in.cleanPath]
isNewMedia := !exists
if exists && in.ext != ".strm" && existing.SizeBytes == in.size && !localMetadataNeedsRefresh(existing, in.localMeta) && !localDerivedMetadataNeedsRefresh(existing, in.incoming) {
if exists && existing.AvailabilityState != "missing" && existing.MissingSince == nil && in.ext != ".strm" && existing.SizeBytes == in.size && !localMetadataNeedsRefresh(existing, in.localMeta) && !localDerivedMetadataNeedsRefresh(existing, in.incoming) {
// Unchanged rows are skipped, but a row that never received ffprobe data
// still needs a backfill probe so installing ffmpeg later repairs the library.
s.queueProbeBackfillIfNeeded(in.path, in.ext, existing)
@@ -372,7 +372,14 @@ func TestScanLibraryPrunesMissingMedia(t *testing.T) {
if err := db.Model(&model.Media{}).Where("library_id = ?", lib.ID).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("media count = %d, want 1", count)
if count != 2 {
t.Fatalf("media count = %d, want current and missing records preserved", count)
}
var missing model.Media
if err := db.First(&missing, "id = ?", stale.ID).Error; err != nil {
t.Fatal(err)
}
if missing.AvailabilityState != "missing" {
t.Fatalf("stale media state = %q, want missing", missing.AvailabilityState)
}
}
+55
View File
@@ -0,0 +1,55 @@
package service
import (
"os"
"path/filepath"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
func TestScanRestoresUnchangedMissingFileAndMetadata(t *testing.T) {
scanner, repos := newScannerTestEnv(t)
root := t.TempDir()
library := model.Library{Name: "Movies", Path: root, Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &library); err != nil {
t.Fatal(err)
}
path := filepath.Join(root, "Example (2024).mkv")
writeTestFile(t, path, "unchanged content")
if _, err := scanner.ScanLibrary(t.Context(), library.ID); err != nil {
t.Fatal(err)
}
var original model.Media
if err := repos.DB.Where("path = ?", path).First(&original).Error; err != nil {
t.Fatal(err)
}
if err := repos.DB.Model(&original).Updates(map[string]any{
"title": "Manually matched title", "scrape_status": "matched", "tm_db_id": 42,
}).Error; err != nil {
t.Fatal(err)
}
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if changed, err := scanner.RemovePath(t.Context(), path); err != nil || changed != 1 {
t.Fatalf("mark missing: changed=%d err=%v", changed, err)
}
if changed, err := scanner.RemovePath(t.Context(), path); err != nil || changed != 0 {
t.Fatalf("repeated missing event: changed=%d err=%v", changed, err)
}
writeTestFile(t, path, "unchanged content")
if _, err := scanner.ScanLibrary(t.Context(), library.ID); err != nil {
t.Fatal(err)
}
var restored model.Media
if err := repos.DB.Where("path = ?", path).First(&restored).Error; err != nil {
t.Fatal(err)
}
if restored.ID != original.ID || restored.TMDbID != 42 || restored.Title != "Manually matched title" {
t.Fatalf("restored file lost identity or metadata: %#v", restored)
}
if restored.AvailabilityState != "available" || restored.MissingSince != nil || restored.LastSeenAt == nil {
t.Fatalf("unchanged restored file stayed missing: %#v", restored)
}
}
+44 -15
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
@@ -12,11 +13,15 @@ import (
"github.com/truewhile/MeBox/internal/model"
)
// RemovePath 移除磁盘上已不存在的媒体记录。普通的删除仍做物理删除;
// 可确认是 STRM 扩展名改名的路径则先保留软删除墓碑,供后续 ingest 继承元数据。
// RemovePath 将确认不存在的媒体标记为失联;只有可确认的 STRM 扩展名改名
// 才保留软删除墓碑,供后续 ingest 继承元数据。
func (s *ScannerService) RemovePath(ctx context.Context, path string) (int64, error) {
if _, err := os.Stat(path); err == nil {
return 0, nil // still exists; nothing to remove
} else if !errors.Is(err, os.ErrNotExist) {
// Permission, I/O, and mount errors are not evidence that a file was
// removed. Preserve records until the path can be inspected reliably.
return 0, nil
}
// 目录整体消失(删除/改名离开):连同其子树下的媒体行一并移除。
// 此前只删 path 精确匹配的行——目录本身通常没有 media 行,导致目录
@@ -33,7 +38,7 @@ func (s *ScannerService) RemovePath(ctx context.Context, path string) (int64, er
return 0, err
}
softIDs := make([]string, 0, len(rows))
hardIDs := make([]string, 0, len(rows))
missingIDs := make([]string, 0, len(rows))
for _, row := range rows {
// LIKE 里的 % _ 是通配符(候选集只会偏大),用 Go 前缀精确过滤,
// 避免对含 % / _ 的路径误删。
@@ -43,12 +48,12 @@ func (s *ScannerService) RemovePath(ctx context.Context, path string) (int64, er
// A vanished STRM with a live sibling (foo.strm <-> foo.mkv.strm)
// is a rename. Keep a soft-deleted tombstone so the later ingest can
// adopt its ID and scraped metadata even if fsnotify delivers the
// remove event before the create event. A true deletion stays hard.
// remove event before the create event.
if row.Path == path && liveSTRMPathAlias(path) {
softIDs = append(softIDs, row.ID)
continue
}
hardIDs = append(hardIDs, row.ID)
missingIDs = append(missingIDs, row.ID)
}
var removed int64
if len(softIDs) > 0 {
@@ -60,14 +65,12 @@ func (s *ScannerService) RemovePath(ctx context.Context, path string) (int64, er
}
removed += res.RowsAffected
}
if len(hardIDs) > 0 {
res := s.repo.DB.WithContext(ctx).Unscoped().
Where("id IN ?", hardIDs).
Delete(&model.Media{})
if res.Error != nil {
return removed, res.Error
if len(missingIDs) > 0 {
count, err := s.markMediaMissingByIDs(ctx, missingIDs)
if err != nil {
return removed, err
}
removed += res.RowsAffected
removed += count
}
if removed > 0 {
s.invalidateMediaCache(ctx)
@@ -82,7 +85,7 @@ const strmRenameTombstoneRetention = 7 * 24 * time.Hour
// purgeExpiredSTRMTombstones keeps the soft-delete grace period bounded.
// It is deliberately restricted to deleted media rows under a STRM alias
// workflow; normal deletions are still hard-deleted immediately.
// workflow; ordinary missing files retain their metadata for recovery.
func (s *ScannerService) purgeExpiredSTRMTombstones(ctx context.Context) {
cutoff := time.Now().Add(-strmRenameTombstoneRetention)
if err := s.repo.DB.WithContext(ctx).Unscoped().
@@ -121,7 +124,7 @@ func (s *ScannerService) pruneMissingMedia(ctx context.Context, libraryID string
}
stale = append(stale, row.ID)
}
return s.deleteMediaByIDs(ctx, stale, true)
return s.markMediaMissingByIDs(ctx, stale)
}
func (s *ScannerService) pruneMissingMediaForRoot(ctx context.Context, libraryID, rootID, rootPath string, seen map[string]struct{}) (int64, error) {
@@ -158,7 +161,33 @@ func (s *ScannerService) pruneMissingMediaForRoot(ctx context.Context, libraryID
}
stale = append(stale, row.ID)
}
return s.deleteMediaByIDs(ctx, stale, true)
return s.markMediaMissingByIDs(ctx, stale)
}
func (s *ScannerService) markMediaMissingByIDs(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
const batch = 500
var changed int64
now := time.Now()
for i := 0; i < len(ids); i += batch {
end := i + batch
if end > len(ids) {
end = len(ids)
}
result := s.repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("id IN ? AND (availability_state IS NULL OR availability_state <> ?)", ids[i:end], "missing").
Updates(map[string]any{"availability_state": "missing", "missing_since": now})
if result.Error != nil {
return changed, result.Error
}
changed += result.RowsAffected
}
if changed > 0 {
s.invalidateMediaCache(ctx)
}
return changed, nil
}
func pathBelongsToRoot(pathValue, rootPath string) bool {
+1 -1
View File
@@ -202,7 +202,7 @@ func (s *ScannerService) scanLocalLibraryFiles(ctx context.Context, lib *model.L
func existingLocalMediaFileIDs(existingMedia map[string]existingLocalMedia) map[string]string {
seenInodes := make(map[string]string)
for path, existing := range existingMedia {
if existing.FileID != "" {
if existing.FileID != "" && existing.AvailabilityState != "missing" {
seenInodes[existing.FileID] = path
}
}
+10 -3
View File
@@ -175,7 +175,7 @@ func TestIngestPathAdoptsSoftDeletedSTRMRenameTombstone(t *testing.T) {
}
}
func TestRemovePathStillHardDeletesTrueDeletionWithoutAlias(t *testing.T) {
func TestRemovePathRetainsTrueDeletionWithoutAlias(t *testing.T) {
sc, repos := newScannerTestEnv(t)
root := t.TempDir()
lib := model.Library{Name: "Anime", Path: root, Type: "anime", Enabled: true}
@@ -197,8 +197,15 @@ func TestRemovePathStillHardDeletesTrueDeletionWithoutAlias(t *testing.T) {
if err := repos.DB.Unscoped().Model(&model.Media{}).Where("path = ?", path).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("true deletion left %d tombstone row(s)", count)
if count != 1 {
t.Fatalf("missing media count = %d, want metadata retained", count)
}
var media model.Media
if err := repos.DB.Where("path = ?", path).First(&media).Error; err != nil {
t.Fatal(err)
}
if media.AvailabilityState != "missing" || media.MissingSince == nil {
t.Fatalf("true deletion did not record missing state: %#v", media)
}
}
+12
View File
@@ -50,6 +50,7 @@ type SchedulerService struct {
readerFilesTTLHours int
segments *MediaSegmentService
backup *BackupService
mu sync.Mutex
stopCh chan struct{}
@@ -65,6 +66,10 @@ func (s *SchedulerService) SetTaskTracker(tasks *TaskTrackerService) {
s.tasks = tasks
}
func (s *SchedulerService) SetBackupService(backup *BackupService) {
s.backup = backup
}
func (s *SchedulerService) SetOrganizePipeline(pipeline *OrganizePipelineService) {
s.organizePipeline = pipeline
}
@@ -196,6 +201,13 @@ func (s *SchedulerService) Start(ctx context.Context) {
run: s.jobTelegramExpiryWarning,
})
}
if s.backup != nil {
s.jobs = append(s.jobs, &scheduledJob{
name: "database_backup",
interval: 5 * time.Minute,
run: s.jobDatabaseBackup,
})
}
for _, j := range s.jobs {
initialDelay := 15 * time.Second
if j.name == "library_scan" || j.name == "organize_source" || j.name == "segment_prewarm" {
+47
View File
@@ -11,6 +11,53 @@ import (
"go.uber.org/zap"
)
const lastAutomaticBackupDateKey = "backup.last_automatic_date"
func (s *SchedulerService) jobDatabaseBackup(ctx context.Context) error {
if s == nil || s.backup == nil || s.repo == nil || s.repo.Setting == nil {
return nil
}
enabled, _ := s.repo.Setting.Get(ctx, "backup.auto_enabled")
if !parseBoolSetting(enabled, false) {
return nil
}
clock, _ := s.repo.Setting.Get(ctx, "backup.auto_time")
if clock == "" {
clock = "03:00"
}
parsed, err := time.Parse("15:04", clock)
if err != nil {
return nil
}
now := s.currentTime().In(time.Local)
dueAt := time.Date(now.Year(), now.Month(), now.Day(), parsed.Hour(), parsed.Minute(), 0, 0, now.Location())
if now.Before(dueAt) {
return nil
}
date := now.Format("2006-01-02")
last, _ := s.repo.Setting.Get(ctx, lastAutomaticBackupDateKey)
if last == date {
return nil
}
if _, err := s.backup.CreateAutomatic(ctx); err != nil {
return err
}
// Record the successful backup before pruning. If retention cleanup fails,
// the next scheduler tick must not create another copy for the same day.
if err := s.repo.Setting.Set(ctx, lastAutomaticBackupDateKey, date); err != nil {
return err
}
retentionValue, _ := s.repo.Setting.Get(ctx, "backup.retention")
retention, err := strconv.Atoi(strings.TrimSpace(retentionValue))
if err != nil || retention < 1 {
retention = 7
}
if err := s.backup.Prune(retention); err != nil {
return err
}
return nil
}
// jobScanLibraries re-walks every enabled library.
//
// 默认关闭:文件变更由 WatcherService 增量入库,无需周期性全量重扫。
+4
View File
@@ -63,6 +63,9 @@ func (b *serviceContainerBuilder) startRealtimeServices() {
b.c.WSHub = NewHub(b.log)
helper.Go(b.log, "ws.hub", b.c.WSHub.Run)
b.c.Tasks = NewTaskTrackerService(b.log, b.c.WSHub)
if b.repos != nil {
b.c.Tasks.SetPersistence(b.repos.DB)
}
b.c.SystemUpdate = NewSystemUpdateService(b.cfg, b.log, b.repos, b.c.Tasks, b.version)
b.c.SSEHub = NewSSEHub(b.log)
@@ -206,6 +209,7 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() {
b.c.Organizer, b.c.WSHub, b.cfg.Cache.CacheDir,
)
b.c.Scheduler.SetTaskTracker(b.c.Tasks)
b.c.Scheduler.SetBackupService(b.c.Backup)
b.c.Scheduler.SetOrganizePipeline(b.c.OrganizePipeline)
b.c.Scheduler.SetSegments(b.c.Segments)
b.c.Scheduler.SetImageCachePolicyProvider(func() ImageCachePolicy {
@@ -3,11 +3,13 @@ package service
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"go.uber.org/zap"
@@ -15,6 +17,7 @@ import (
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service/cloud"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
// directRedirectTestRepo 建一个带网盘账号表的库:normalizeCloudPlayTarget 需要
@@ -145,6 +148,53 @@ func TestServeFileFallsBackWhenDirectResolveUnavailable(t *testing.T) {
}
}
// `?refresh=1`:客户端手上的直链已被 CDN 403,服务端必须丢掉缓存重新换链,
// 不能把同一条死链再发一次;不带该参数时缓存照旧命中,保住快路径。
func TestServeFileRefreshDropsCachedDirectLink(t *testing.T) {
repos := directRedirectTestRepo(t)
seedCloudSTRMMedia(t, repos, "cloud-refresh", "/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc-refresh")
cached := fmt.Sprintf("https://cdn.example/stale.mkv?t=%d", time.Now().Add(30*time.Minute).Unix())
fresh := "https://cdn.example/fresh.mkv?t=2"
cloud115.SetDownloadURLCache("pc-refresh", cached, "RodelPlayer/2.0")
t.Cleanup(func() { cloud115.ClearDownloadURLCache("pc-refresh") })
oldMinAge := directLinkRefreshMinAge
directLinkRefreshMinAge = 0
defer func() { directLinkRefreshMinAge = oldMinAge }()
run := func(t *testing.T, query string) (cachedAtResolve, location string) {
t.Helper()
svc := NewStreamService(&config.Config{}, zap.NewNop(), repos, nil).
SetStrmPlayTargetResolver(func(_ context.Context, _ string, userAgent string) (*StrmPlayResult, error) {
cachedAtResolve = cloud115.GetDownloadURLCache("pc-refresh", userAgent)
return &StrmPlayResult{
RedirectURL: fresh,
Link: &cloud.DirectLink{URL: fresh, Headers: map[string]string{"User-Agent": userAgent}},
}, nil
})
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/cloud-refresh?token=jwt123"+query, nil)
req.Header.Set("User-Agent", "RodelPlayer/2.0")
w := httptest.NewRecorder()
if err := svc.ServeFile(w, req, "cloud-refresh"); err != nil {
t.Fatalf("ServeFile: %v", err)
}
return cachedAtResolve, w.Header().Get("Location")
}
// 正常播放:缓存里的直链照旧命中,服务端不该白跑一次换链。
if got, loc := run(t, ""); got != cached {
t.Fatalf("cached link = %q, want the cached %q", got, cached)
} else if loc != fresh {
t.Fatalf("Location = %q, want the resolved direct link", loc)
}
// 客户端报告直链失效:换链前必须先清掉缓存。
if got, loc := run(t, "&refresh=1"); got != "" {
t.Fatalf("cached link must be dropped before resolving, got %q", got)
} else if loc != fresh {
t.Fatalf("Location = %q, want the freshly resolved direct link", loc)
}
}
// 没有注入解析器(测试/精简部署)时保持原有跳转,不受本次优化影响。
func TestServeFileKeepsSTRMEndpointWithoutResolver(t *testing.T) {
repos := directRedirectTestRepo(t)
+79 -1
View File
@@ -10,6 +10,7 @@ import (
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
// ServeFile streams the file backing the given media ID using
@@ -22,6 +23,47 @@ func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaI
return s.ServeFileWithCloudMode(w, r, mediaID, "")
}
// PickCodeFromPlayTarget 从 /api/strm/play/... 形式的播放目标里取出 pickcode。
// 115 直链缓存按 pickcode 分键,客户端要求强制换链时用它定位缓存条目。
func PickCodeFromPlayTarget(raw string) string {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u == nil {
return ""
}
return strings.TrimSpace(u.Query().Get("pickcode"))
}
// directLinkRefreshMinAge 是「强制换链」的最小间隔:缓存条目刚写入不久就
// 收到换链请求时忽略它,避免客户端重试风暴把 115 换链接口打出突发。
// 测试会临时改成 0,让「CDN 拒绝 → 立刻换链」的路径可验证。
var directLinkRefreshMinAge = 30 * time.Second
// wantsFreshLink 判断客户端是否要求跳过直链缓存重新换链(`?refresh=1`)。
//
// 115 直链只有 30 分钟有效期,客户端拿到过期直链被 CDN 403 后需要一条新链;
// 正常路径下缓存会先于直链过期,这里是客户端上报失败时的兜底。
func wantsFreshLink(r *http.Request) bool {
if r == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("refresh"))) {
case "1", "true", "yes":
return true
default:
return false
}
}
// InvalidateMediaLinkCache 清除媒体播放目标对应的云盘直链缓存,让下一次解析
// 重新换链。非云盘/无 pickcode 的媒体是空操作。
func InvalidateMediaLinkCache(m *model.Media) {
pickCode := PickCodeFromPlayTarget(MediaSTRMTarget(m))
if pickCode == "" {
return
}
cloud115.ClearDownloadURLCacheIfOlderThan(pickCode, directLinkRefreshMinAge)
}
// MediaSTRMTarget 返回媒体行固化的 STRM 播放目标。STRMURL 为空时回读本地
// .strm 文件内容兜底(扫描时内容解析失败的行只剩 Container=strm + Path),
// 仍拿不到返回空串。
@@ -57,6 +99,11 @@ func (s *StreamService) ServeFileWithCloudMode(w http.ResponseWriter, r *http.Re
// 能在服务端换到最终直链就直接 302 过去:客户端少跟随一次 302,等于
// 省掉一次「DNS+TCP+TLS+请求」的往返。解析结果同时写进 strm 层直链
// 缓存,后续 /api/strm/play 请求直接命中。
// `?refresh=1` 表示客户端手上的直链已经失效(被 CDN 403),要求丢掉
// 缓存重新换链。
if wantsFreshLink(r) {
InvalidateMediaLinkCache(m)
}
if direct, ok := s.resolveDirectPlayTargetURL(r, strmURL); ok {
setCloudRedirectNoStore(w)
http.Redirect(w, r, direct, http.StatusFound)
@@ -110,6 +157,29 @@ func setCloudRedirectNoStore(w http.ResponseWriter) {
// 由 /api/strm/play 再去换链,最坏情况只是回到改动前的行为。
const directPlayResolveTimeout = 10 * time.Second
// directPlayInfoResolveTimeout 是播放地址响应(/api/playback/{id}/info)里
// 预换链的等待上限。这条路径是用户点播放后的同步阻塞,宁可退回 /api/stream
// 跳转,也不能把起播卡在换链上;命中直链缓存时通常只有微秒级。
const directPlayInfoResolveTimeout = 3 * time.Second
// ResolveDirectPlayURL 尝试把媒体的 STRM/网盘目标换成客户端可直接播放的最终
// 直链,供 /api/playback/{id}/info 预置给客户端。
//
// 客户端拿到直链后可以跳过 /api/stream 的 302 往返;在高延迟/跨境链路上,
// 这一次「DNS + TCP + TLS + 请求」常常是数秒,正好落在起播关键路径上。
// 返回 false 表示必须继续走 /api/stream(本地文件、需要服务端加头的网盘、
// 解析失败或超时),行为与改动前一致。
func (s *StreamService) ResolveDirectPlayURL(r *http.Request, m *model.Media) (string, bool) {
if s == nil || r == nil || m == nil {
return "", false
}
raw := MediaSTRMTarget(m)
if raw == "" || !playableSTRMTarget(r.Context(), s.repo, raw, m) {
return "", false
}
return s.resolveDirectPlayTargetURLWithin(r, raw, directPlayInfoResolveTimeout)
}
// resolveDirectPlayTargetURL 尝试在服务端把 strm 目标解析成客户端可直接拉取的
// 最终直链,供调用方直接 302。
//
@@ -117,10 +187,18 @@ const directPlayResolveTimeout = 10 * time.Second
// 解析到本地文件、以及解析失败都会返回 false,由调用方按改动前的方式回退到
// strm 端点跳转,行为不会变差。
func (s *StreamService) resolveDirectPlayTargetURL(r *http.Request, raw string) (string, bool) {
return s.resolveDirectPlayTargetURLWithin(r, raw, directPlayResolveTimeout)
}
func (s *StreamService) resolveDirectPlayTargetURLWithin(
r *http.Request,
raw string,
timeout time.Duration,
) (string, bool) {
if s == nil || s.strmResolve == nil || r == nil {
return "", false
}
ctx, cancel := context.WithTimeout(r.Context(), directPlayResolveTimeout)
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()
// 必须带播放器 UA:115 直链绑定换取时的 UA,且按 UA 分键缓存,换错会拿到
// 与播放器不匹配(或未命中缓存)的地址。
@@ -0,0 +1,89 @@
package service
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service/cloud"
)
// /api/playback/{id}/info 预置换链:能换到直链时返回 true,并把客户端 UA
// 透传给 115(直链绑定换取时的 UA,播放器必须用同一个)。
func TestResolveDirectPlayURLForPlaybackInfo(t *testing.T) {
const direct = "https://cdnfhnfile.115cdn.net/637b/Movie.mkv?t=1&k=sig"
var gotUA string
svc := NewStreamService(&config.Config{}, zap.NewNop(), nil, nil).
SetStrmPlayTargetResolver(func(_ context.Context, _, userAgent string) (*StrmPlayResult, error) {
gotUA = userAgent
return &StrmPlayResult{
RedirectURL: direct,
Link: &cloud.DirectLink{URL: direct, Headers: map[string]string{"User-Agent": userAgent}},
}, nil
})
m := &model.Media{
Base: model.Base{ID: "cloud-info"},
Path: "cloud://cloud115/Movie.mkv",
Container: "strm",
STRMURL: "https://mebox.example/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1",
}
req := httptest.NewRequest(http.MethodGet, "/api/playback/cloud-info/info", nil)
req.Header.Set("User-Agent", "MeBox-Android/0.1.0")
got, ok := svc.ResolveDirectPlayURL(req, m)
if !ok || got != direct {
t.Fatalf("ResolveDirectPlayURL() = (%q, %v), want (%q, true)", got, ok, direct)
}
if gotUA != "MeBox-Android/0.1.0" {
t.Fatalf("resolver UA = %q, want the requesting client UA", gotUA)
}
}
// 本地文件没有可直连的 CDN 地址,信息接口不能凭空给出 direct_url。
func TestResolveDirectPlayURLSkipsLocalMedia(t *testing.T) {
svc := NewStreamService(&config.Config{}, zap.NewNop(), nil, nil).
SetStrmPlayTargetResolver(func(context.Context, string, string) (*StrmPlayResult, error) {
t.Fatal("本地文件不应该触发换链")
return nil, nil
})
m := &model.Media{
Base: model.Base{ID: "local-1"},
Path: "/media/movie.mkv",
Container: "mkv",
}
req := httptest.NewRequest(http.MethodGet, "/api/playback/local-1/info", nil)
if got, ok := svc.ResolveDirectPlayURL(req, m); ok || got != "" {
t.Fatalf("ResolveDirectPlayURL() = (%q, %v), want (\"\", false)", got, ok)
}
}
// 需要服务端附加其它请求头(Referer 等)的直链不能短路给客户端,否则播放端
// 少了鉴权头会 403;这类媒体继续走 /api/stream 由服务端转发。
func TestResolveDirectPlayURLSkipsHeaderBoundLinks(t *testing.T) {
const direct = "https://cdn.example.com/Movie.mkv"
svc := NewStreamService(&config.Config{}, zap.NewNop(), nil, nil).
SetStrmPlayTargetResolver(func(_ context.Context, _, userAgent string) (*StrmPlayResult, error) {
return &StrmPlayResult{
RedirectURL: direct,
Link: &cloud.DirectLink{
URL: direct,
Headers: map[string]string{"User-Agent": userAgent, "Referer": "https://pan.example"},
},
}, nil
})
m := &model.Media{
Base: model.Base{ID: "cloud-header"},
Path: "cloud://cloud115/Movie.mkv",
Container: "strm",
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1",
}
req := httptest.NewRequest(http.MethodGet, "/api/playback/cloud-header/info", nil)
if got, ok := svc.ResolveDirectPlayURL(req, m); ok || got != "" {
t.Fatalf("ResolveDirectPlayURL() = (%q, %v), want (\"\", false)", got, ok)
}
}
+82 -11
View File
@@ -16,6 +16,7 @@ import (
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service/cloud"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
// ErrStrmPlayNotFound 表示 strm 播放目标不存在(handler 返回 404)。
@@ -250,21 +251,87 @@ func (s *StrmService) ProxyMediaDirect(ctx context.Context, w http.ResponseWrite
if err != nil {
return err
}
switch {
case result.Link != nil && result.Link.URL != "":
return s.ProxyDirect(ctx, w, r, result.Link)
case result.RedirectURL != "":
return s.ProxyDirect(ctx, w, r, &cloud.DirectLink{URL: result.RedirectURL})
default:
link := directLinkOfPlayResult(result)
if link == nil {
// 本地文件(LocalPath)由静态文件处理器提供,本身就是同源。
return ErrStrmProxyNotApplicable
}
resp, method, err := s.openDirectUpstream(ctx, r, link)
if err != nil {
return err
}
// 直链被 CDN 拒绝:115 直链带 30 分钟过期时间戳,缓存命中也可能拿到已经
// 失效的地址。这里清掉缓存重新换链再试一次,避免客户端只看到 403。
if directLinkRejected(resp.StatusCode) {
_ = resp.Body.Close()
if fresh := s.refreshDirectLink(ctx, raw); fresh != nil {
if retry, retryMethod, retryErr := s.openDirectUpstream(ctx, r, fresh); retryErr == nil {
resp, method = retry, retryMethod
}
}
}
defer resp.Body.Close()
return writeDirectUpstream(w, resp, method)
}
// directLinkOfPlayResult 取出可反向代理的直链;本地文件返回 nil。
func directLinkOfPlayResult(result *StrmPlayResult) *cloud.DirectLink {
if result == nil {
return nil
}
switch {
case result.Link != nil && result.Link.URL != "":
return result.Link
case result.RedirectURL != "":
return &cloud.DirectLink{URL: result.RedirectURL}
default:
return nil
}
}
// directLinkRejected 判断上游状态码是否属于「直链本身失效」(而不是文件/权限
// 之类与链接无关的问题)。
func directLinkRejected(status int) bool {
switch status {
case http.StatusForbidden, http.StatusNotFound, http.StatusGone:
return true
default:
return false
}
}
// refreshDirectLink 丢弃当前直链缓存并重新换链,返回新的直链;无法换链时返回
// nil,调用方继续用原来那条。
func (s *StrmService) refreshDirectLink(ctx context.Context, raw string) *cloud.DirectLink {
pickCode := PickCodeFromPlayTarget(raw)
if pickCode == "" {
return nil
}
if !cloud115.ClearDownloadURLCacheIfOlderThan(pickCode, directLinkRefreshMinAge) {
// 缓存刚写入不久:说明失败与链接过期无关,重换一次也会拿到同一条。
return nil
}
fresh, err := s.ResolvePlayTarget(ctx, raw)
if err != nil {
return nil
}
return directLinkOfPlayResult(fresh)
}
// ProxyDirect 反向代理渲染直链内容(保留 Range 请求头以支持拖动播放)。
func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, link *cloud.DirectLink) error {
resp, method, err := s.openDirectUpstream(ctx, r, link)
if err != nil {
return err
}
defer resp.Body.Close()
return writeDirectUpstream(w, resp, method)
}
// openDirectUpstream 向网盘直链发起一次请求,保留 Range 与 UA 语义。
func (s *StrmService) openDirectUpstream(ctx context.Context, r *http.Request, link *cloud.DirectLink) (*http.Response, string, error) {
if link == nil || link.URL == "" {
return errors.New("空直链")
return nil, "", errors.New("空直链")
}
method := http.MethodGet
if r != nil && r.Method == http.MethodHead {
@@ -272,7 +339,7 @@ func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r
}
req, err := http.NewRequestWithContext(ctx, method, link.URL, nil)
if err != nil {
return err
return nil, "", err
}
for k, v := range link.Headers {
req.Header.Set(k, v)
@@ -288,9 +355,13 @@ func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r
}
resp, err := s.streamHTTP.Do(req)
if err != nil {
return err
return nil, "", err
}
defer resp.Body.Close()
return resp, method, nil
}
// writeDirectUpstream 把上游响应原样写给客户端。
func writeDirectUpstream(w http.ResponseWriter, resp *http.Response, method string) error {
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified"} {
if value := resp.Header.Get(header); value != "" {
w.Header().Set(header, value)
@@ -310,7 +381,7 @@ func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r
if flusher, ok := w.(http.Flusher); ok {
writer = &flushWriter{writer: w, flusher: flusher}
}
_, err = io.Copy(writer, resp.Body)
_, err := io.Copy(writer, resp.Body)
return err
}
+132
View File
@@ -3,12 +3,16 @@ package service
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"sync"
"testing"
"time"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
// 同源转发必须原样保留 Range 语义:把 206 改写成 200 会让浏览器误判响应长度,
@@ -55,6 +59,134 @@ func TestProxyMediaDirectForwardsRangeAndStatus(t *testing.T) {
}
}
// 115 直链只有 30 分钟寿命,缓存命中也可能拿到已经失效的地址。同源转发遇到
// CDN 403 时必须丢掉缓存重新换链再试一次,而不是把 403 直接透给客户端。
func TestProxyMediaDirectRetriesWithFreshLinkWhenCDNRejects(t *testing.T) {
svc := testStrmService(t)
acct := &model.StrmAccount{Name: "fake115", Provider: model.StrmProvider115, Config: "{}", Enabled: true}
if err := svc.repo.StrmAccount.Create(context.Background(), acct); err != nil {
t.Fatal(err)
}
cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/fresh.mkv" {
w.WriteHeader(http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "video/x-matroska")
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write([]byte("fresh-bytes"))
}))
defer cdn.Close()
// 第一次换链给出「已过期」的旧地址,重新换链才给新地址。
var mu sync.Mutex
var calls int
expiry := time.Now().Add(30 * time.Minute).Unix()
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
calls++
attempt := calls
mu.Unlock()
link := cdn.URL + "/old.mkv"
if attempt > 1 {
link = cdn.URL + "/fresh.mkv"
}
_, _ = fmt.Fprintf(w, `{"state":true,"data":{"1":{"pick_code":"pc-retry","url":{"url":%q}}}}`,
fmt.Sprintf("%s?t=%d&u=1&k=abc", link, expiry))
}))
defer api.Close()
oldPro := cloud115.ProAPIBase
cloud115.ProAPIBase = api.URL
defer func() { cloud115.ProAPIBase = oldPro }()
defer cloud115.ClearDownloadURLCache("pc-retry")
oldMinAge := directLinkRefreshMinAge
directLinkRefreshMinAge = 0
defer func() { directLinkRefreshMinAge = oldMinAge }()
media := &model.Media{
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=" + acct.ID + "&pickcode=pc-retry",
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/stream/media-1?proxy=1", nil)
req.Header.Set("User-Agent", "MeBoxTest/1.0")
if err := svc.ProxyMediaDirect(context.Background(), rec, req, media); err != nil {
t.Fatalf("ProxyMediaDirect: %v", err)
}
if rec.Code != http.StatusPartialContent {
t.Fatalf("status = %d, want 206 (body=%s)", rec.Code, rec.Body.String())
}
if rec.Body.String() != "fresh-bytes" {
t.Fatalf("body = %q, want the fresh link payload", rec.Body.String())
}
mu.Lock()
defer mu.Unlock()
if calls != 2 {
t.Fatalf("downurl calls = %d, want 2 (one retry after the CDN rejected the stale link)", calls)
}
}
// 换链后依然是同一条失效直链时,保持原有行为:原样透传 CDN 的状态码。
func TestProxyMediaDirectPassesRejectedStatusWhenRefreshCannotHelp(t *testing.T) {
svc := testStrmService(t)
acct := &model.StrmAccount{Name: "fake115", Provider: model.StrmProvider115, Config: "{}", Enabled: true}
if err := svc.repo.StrmAccount.Create(context.Background(), acct); err != nil {
t.Fatal(err)
}
cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer cdn.Close()
expiry := time.Now().Add(30 * time.Minute).Unix()
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, `{"state":true,"data":{"1":{"pick_code":"pc-dead","url":{"url":%q}}}}`,
fmt.Sprintf("%s/dead.mkv?t=%d&u=1&k=abc", cdn.URL, expiry))
}))
defer api.Close()
oldPro := cloud115.ProAPIBase
cloud115.ProAPIBase = api.URL
defer func() { cloud115.ProAPIBase = oldPro }()
defer cloud115.ClearDownloadURLCache("pc-dead")
oldMinAge := directLinkRefreshMinAge
directLinkRefreshMinAge = 0
defer func() { directLinkRefreshMinAge = oldMinAge }()
media := &model.Media{
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=" + acct.ID + "&pickcode=pc-dead",
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/stream/media-1?proxy=1", nil)
if err := svc.ProxyMediaDirect(context.Background(), rec, req, media); err != nil {
t.Fatalf("ProxyMediaDirect: %v", err)
}
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want the upstream 403 passed through", rec.Code)
}
}
// 强制换链只认 /api/strm/play 形式播放目标里的 pickcode。
func TestPickCodeFromPlayTarget(t *testing.T) {
cases := []struct {
raw string
want string
}{
{"/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1", "pc1"},
{"https://nas.local/api/strm/play/cloud115/video.mkv?pickcode=pc2&acct=a1", "pc2"},
{"/api/strm/play/cloud115/video.mkv?acct=a1", ""},
{"https://cdn.example.test/Movie.mkv?sign=1", ""},
{"", ""},
}
for _, tc := range cases {
if got := PickCodeFromPlayTarget(tc.raw); got != tc.want {
t.Fatalf("PickCodeFromPlayTarget(%q) = %q, want %q", tc.raw, got, tc.want)
}
}
}
// 本地文件本身就是同源资源,不应该被代理(调用方按原静态文件逻辑处理)。
func TestProxyMediaDirectSkipsLocalFile(t *testing.T) {
svc := testStrmService(t)
+81 -2
View File
@@ -182,6 +182,77 @@ func (s *SubtitleService) discoverExternalUncached(ctx context.Context, mediaID
return discoverExternalSubtitleTracks(m), nil
}
// subtitleLanguageNames 把文件名后缀/容器里的语言标记折成可读名称。
//
// 外挂字幕只能从文件名拿到语言(`movie.chs.ass` → `chs`),直接展示给用户
// 就是「chs」这种看不懂的值;内嵌轨没有 title 标签时同理。认不出来的标记
// 原样保留,交给调用方(例如 Emby 的「字幕 (ASS)」兜底)处理。
var subtitleLanguageNames = map[string]string{
"zh": "中文",
"chi": "中文",
"zho": "中文",
"cn": "中文",
"chs": "简体中文",
"sc": "简体中文",
"zh-cn": "简体中文",
"zh-hans": "简体中文",
"zh-sg": "简体中文",
"cht": "繁体中文",
"tc": "繁体中文",
"zh-tw": "繁体中文",
"zh-hk": "繁体中文",
"zh-mo": "繁体中文",
"zh-hant": "繁体中文",
"jpn": "日本語",
"ja": "日本語",
"jp": "日本語",
"eng": "English",
"en": "English",
"kor": "한국어",
"ko": "한국어",
"yue": "粤语",
}
// subtitleLanguageName 返回语言标记的可读名称,认不出来时返回空串。
func subtitleLanguageName(raw string) string {
tag := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(raw, "_", "-")))
if tag == "" {
return ""
}
if name, ok := subtitleLanguageNames[tag]; ok {
return name
}
// `chs[bilibiliid_danmu]`、`chs.ass` 这类带附加信息的标记先取前缀。
head := tag
if i := strings.IndexAny(head, "[.@"); i > 0 {
head = strings.TrimSpace(head[:i])
}
return subtitleLanguageNames[head]
}
// subtitleDisplayLabel 把语言标记换成可读名称;认不出来时保留原值(含 und)。
func subtitleDisplayLabel(raw string) string {
if name := subtitleLanguageName(raw); name != "" {
return name
}
return strings.TrimSpace(raw)
}
// isDanmakuSubtitle 报告字幕文件是否是弹幕转存(`xxx[BilibiliID_danmu].ass`)。
// 这类轨道是飘过的弹幕而不是对白字幕,名称里要标出来,避免和对白字幕混淆。
func isDanmakuSubtitle(name string) bool {
lower := strings.ToLower(name)
return strings.Contains(lower, "danmu") ||
strings.Contains(lower, "danmaku") ||
strings.Contains(name, "弹幕")
}
func discoverExternalSubtitleTracks(m *model.Media) []SubtitleTrack {
dir := filepath.Dir(m.Path)
bases := mediaSidecarBaseVariants(m.Path)
@@ -228,9 +299,13 @@ func discoverExternalSubtitleTracks(m *model.Media) []SubtitleTrack {
matchedBase = bases[0]
}
lang := detectLang(fullName, matchedBase)
label := subtitleDisplayLabel(lang)
if isDanmakuSubtitle(e.Name()) {
label += " · 弹幕"
}
tracks = append(tracks, SubtitleTrack{
Lang: lang,
Label: lang,
Label: label,
Path: filepath.Join(c, e.Name()),
Codec: codec,
Source: "external",
@@ -319,7 +394,11 @@ func subtitleTracksFromProbe(probe embeddedSubtitleProbe) []SubtitleTrack {
}
label := strings.TrimSpace(stream.Tags.Title)
if label == "" {
label = lang
// 没有 title 标签时用可读语言名(chi → 中文)代替裸语言码。
label = subtitleDisplayLabel(lang)
}
if label == "" {
label = "字幕"
}
if stream.Disposition.Forced != 0 {
label += "(强制)"
+100
View File
@@ -202,3 +202,103 @@ func TestSubtitleServeRawWritesSourceBytes(t *testing.T) {
t.Fatal("ServeRaw accepted a non-subtitle file")
}
}
func TestSubtitleLanguageNameMapsCommonTags(t *testing.T) {
cases := map[string]string{
"chs": "简体中文",
"CHS": "简体中文",
"zh-Hans": "简体中文",
"cht": "繁体中文",
"chi": "中文",
"zh": "中文",
"jpn": "日本語",
"eng": "English",
"kor": "한국어",
"chs[bilibiliid_danmu]": "简体中文",
"und": "",
"": "",
}
for raw, want := range cases {
if got := subtitleLanguageName(raw); got != want {
t.Errorf("subtitleLanguageName(%q) = %q, want %q", raw, got, want)
}
}
}
func TestSubtitleDisplayLabelKeepsUnknownTags(t *testing.T) {
// 认不出来的标记原样返回:Emby 的 DisplayTitle 依赖 `und` 走「字幕 (ASS)」兜底。
if got := subtitleDisplayLabel("und"); got != "und" {
t.Fatalf("subtitleDisplayLabel(und) = %q, want und", got)
}
if got := subtitleDisplayLabel("weird"); got != "weird" {
t.Fatalf("subtitleDisplayLabel(weird) = %q, want weird", got)
}
}
func TestIsDanmakuSubtitle(t *testing.T) {
if !isDanmakuSubtitle("S01E01.chs[BilibiliID_danmu].ass") {
t.Fatal("danmaku sidecar was not detected")
}
if !isDanmakuSubtitle("弹幕.ass") {
t.Fatal("Chinese danmaku name was not detected")
}
if isDanmakuSubtitle("S01E01.chs.ass") {
t.Fatal("normal subtitle was misdetected as danmaku")
}
}
func TestDiscoverExternalSubtitleLabelsAreReadable(t *testing.T) {
dir := t.TempDir()
videoPath := filepath.Join(dir, "S01E01.mkv")
files := map[string]string{
"S01E01.chs.ass": "[Script Info]\n",
"S01E01.chs[BilibiliID_danmu].ass": "[Script Info]\n",
}
for name, body := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
m := &model.Media{Path: videoPath}
tracks := discoverExternalSubtitleTracks(m)
if len(tracks) != 2 {
t.Fatalf("len(tracks) = %d, want 2: %#v", len(tracks), tracks)
}
labels := map[string]string{}
for _, track := range tracks {
labels[filepath.Base(track.Path)] = track.Label
}
if got := labels["S01E01.chs.ass"]; got != "简体中文" {
t.Fatalf("chs.ass label = %q, want 简体中文", got)
}
if got := labels["S01E01.chs[BilibiliID_danmu].ass"]; got != "简体中文 · 弹幕" {
t.Fatalf("danmaku label = %q, want 简体中文 · 弹幕", got)
}
}
func TestEmbeddedSubtitleProbeUsesReadableLanguage(t *testing.T) {
var probe embeddedSubtitleProbe
raw := []byte(`{"streams":[
{"index":2,"codec_name":"hdmv_pgs_subtitle","tags":{"language":"chi"},"disposition":{"default":1}},
{"index":3,"codec_name":"subrip","tags":{"language":"jpn"}},
{"index":4,"codec_name":"subrip","tags":{"language":"chi","title":"简日双语"}}
]}`)
if err := json.Unmarshal(raw, &probe); err != nil {
t.Fatal(err)
}
tracks := subtitleTracksFromProbe(probe)
if len(tracks) != 3 {
t.Fatalf("len(tracks) = %d, want 3", len(tracks))
}
if got := tracks[0].Label; got != "中文(默认)(内嵌·图片)" {
t.Fatalf("bitmap label = %q", got)
}
if got := tracks[1].Label; got != "日本語(内嵌)" {
t.Fatalf("text label = %q", got)
}
// 容器自带 title 时优先用 title。
if got := tracks[2].Label; got != "简日双语(内嵌)" {
t.Fatalf("titled label = %q", got)
}
}
+82 -4
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"encoding/json"
"html"
"strings"
"sync"
@@ -9,12 +10,16 @@ import (
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
)
const (
TaskStatusRunning = "running"
TaskStatusCompleted = "completed"
TaskStatusFailed = "failed"
TaskStatusRunning = "running"
TaskStatusCompleted = "completed"
TaskStatusFailed = "failed"
TaskStatusInterrupted = "interrupted"
TaskKindOrganize = "organize"
TaskKindScan = "scan"
@@ -70,6 +75,26 @@ type TaskTrackerService struct {
recent []BackgroundTask
maxRecent int
now func() time.Time
db *gorm.DB
}
// SetPersistence enables durable task history and marks tasks left running by
// a previous process as interrupted. Actual task execution is never resumed
// automatically; this record is an operator-visible recovery point.
func (t *TaskTrackerService) SetPersistence(db *gorm.DB) {
if t == nil || db == nil {
return
}
t.mu.Lock()
t.db = db
now := t.currentTime()
t.mu.Unlock()
err := db.Model(&model.BackgroundTaskRecord{}).
Where("status = ?", TaskStatusRunning).
Updates(map[string]any{"status": TaskStatusInterrupted, "error": "服务在任务完成前重启;请检查文件状态后重新执行", "updated_at": now, "finished_at": now}).Error
if err != nil && t.log != nil {
t.log.Warn("recover interrupted background tasks failed", zap.Error(err))
}
}
type TaskHandle struct {
@@ -105,9 +130,11 @@ func (t *TaskTrackerService) Start(kind, name string, update TaskUpdate) *TaskHa
StartedAt: now,
UpdatedAt: now,
}
applyTaskUpdate(task, update)
t.mu.Lock()
t.active[task.ID] = task
snapshot := cloneBackgroundTask(*task)
t.persist(snapshot)
t.mu.Unlock()
t.publish(snapshot)
return &TaskHandle{tracker: t, id: task.ID}
@@ -132,18 +159,47 @@ func (t *TaskTrackerService) Snapshot() TaskSnapshot {
return TaskSnapshot{}
}
t.mu.Lock()
defer t.mu.Unlock()
active := make([]BackgroundTask, 0, len(t.active))
for _, task := range t.active {
active = append(active, cloneBackgroundTask(*task))
}
recent := make([]BackgroundTask, 0, len(t.recent))
db := t.db
limit := t.maxRecent
if limit < 1 {
limit = 30
}
for _, task := range t.recent {
recent = append(recent, cloneBackgroundTask(task))
}
t.mu.Unlock()
if db != nil {
var rows []model.BackgroundTaskRecord
if err := db.Where("status <> ?", TaskStatusRunning).Order("started_at DESC").Limit(limit).Find(&rows).Error; err == nil {
recent = make([]BackgroundTask, 0, len(rows))
for _, row := range rows {
recent = append(recent, backgroundTaskFromRecord(row))
}
} else if t.log != nil {
t.log.Warn("load background task history failed; using memory history", zap.Error(err))
}
}
return TaskSnapshot{Active: active, Recent: recent}
}
func backgroundTaskFromRecord(row model.BackgroundTaskRecord) BackgroundTask {
task := BackgroundTask{
ID: row.ID, Kind: row.Kind, Name: row.Name, Status: row.Status,
Stage: row.Stage, SourcePath: row.SourcePath, DestPath: row.DestPath,
Message: row.Message, Error: row.Error, StartedAt: row.StartedAt,
UpdatedAt: row.UpdatedAt, FinishedAt: row.FinishedAt,
}
_ = json.Unmarshal([]byte(row.Details), &task.Details)
_ = json.Unmarshal([]byte(row.Metrics), &task.Metrics)
_ = json.Unmarshal([]byte(row.Items), &task.Items)
return task
}
func (t *TaskTrackerService) update(id string, update TaskUpdate) {
now := t.currentTime()
t.mu.Lock()
@@ -155,6 +211,7 @@ func (t *TaskTrackerService) update(id string, update TaskUpdate) {
applyTaskUpdate(task, update)
task.UpdatedAt = now
snapshot := cloneBackgroundTask(*task)
t.persist(snapshot)
t.mu.Unlock()
t.publish(snapshot)
}
@@ -186,6 +243,7 @@ func (t *TaskTrackerService) finish(id string, err error, update TaskUpdate) {
t.recent = t.recent[:t.maxRecent]
}
notifier := t.failureNotifier
t.persist(snapshot)
t.mu.Unlock()
t.publish(snapshot)
@@ -195,6 +253,26 @@ func (t *TaskTrackerService) finish(id string, err error, update TaskUpdate) {
}
}
func (t *TaskTrackerService) persist(task BackgroundTask) {
if t == nil || t.db == nil {
return
}
encode := func(value any) string {
data, _ := json.Marshal(value)
return string(data)
}
row := model.BackgroundTaskRecord{
ID: task.ID, Kind: task.Kind, Name: task.Name, Status: task.Status,
Stage: task.Stage, SourcePath: task.SourcePath, DestPath: task.DestPath,
Message: task.Message, Error: task.Error, Details: encode(task.Details),
Metrics: encode(task.Metrics), Items: encode(task.Items), StartedAt: task.StartedAt,
UpdatedAt: task.UpdatedAt, FinishedAt: task.FinishedAt,
}
if err := t.db.Save(&row).Error; err != nil && t.log != nil {
t.log.Warn("persist background task failed", zap.String("task_id", task.ID), zap.Error(err))
}
}
// SetFailureNotifier 注入任务失败时的管理员通知回调。
func (t *TaskTrackerService) SetFailureNotifier(fn func(ctx context.Context, text string)) {
if t == nil {
@@ -0,0 +1,78 @@
package service
import (
"fmt"
"sync"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
)
func TestTaskTrackerPersistsHistoryAcrossRestart(t *testing.T) {
db := newServiceTestDB(t)
tracker := NewTaskTrackerService(zap.NewNop(), nil)
tracker.SetPersistence(db)
task := tracker.Start(TaskKindScan, "scan", TaskUpdate{Details: []string{"initial detail"}})
task.Update(TaskUpdate{Metrics: map[string]int64{"visited": 42}})
task.Finish(nil, TaskUpdate{Message: "done"})
restarted := NewTaskTrackerService(zap.NewNop(), nil)
restarted.SetPersistence(db)
snapshot := restarted.Snapshot()
if len(snapshot.Active) != 0 || len(snapshot.Recent) != 1 {
t.Fatalf("unexpected history: %#v", snapshot)
}
recent := snapshot.Recent[0]
if recent.Status != TaskStatusCompleted || recent.Metrics["visited"] != 42 || len(recent.Details) != 1 || recent.FinishedAt == nil {
t.Fatalf("task history lost fields: %#v", recent)
}
}
func TestTaskTrackerConcurrentUpdatesDoNotRegressFinishedTasks(t *testing.T) {
db := newServiceTestDB(t)
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
tracker := NewTaskTrackerService(zap.NewNop(), nil)
tracker.SetPersistence(db)
var workers sync.WaitGroup
for i := 0; i < 12; i++ {
workers.Add(1)
go func(index int) {
defer workers.Done()
task := tracker.Start(TaskKindScan, fmt.Sprintf("scan-%d", index), TaskUpdate{})
task.Update(TaskUpdate{Metrics: map[string]int64{"visited": 1}})
task.Finish(nil, TaskUpdate{})
task.Update(TaskUpdate{Message: "late update"})
_ = tracker.Snapshot()
}(i)
}
workers.Wait()
var completed int64
if err := db.Model(&model.BackgroundTaskRecord{}).Where("status = ?", TaskStatusCompleted).Count(&completed).Error; err != nil || completed != 12 {
t.Fatalf("finished task status regressed: completed=%d err=%v", completed, err)
}
if snapshot := tracker.Snapshot(); len(snapshot.Active) != 0 || len(snapshot.Recent) != 12 {
t.Fatalf("inconsistent task snapshot: %#v", snapshot)
}
}
func TestTaskTrackerMarksAbandonedTasksInterrupted(t *testing.T) {
db := newServiceTestDB(t)
tracker := NewTaskTrackerService(zap.NewNop(), nil)
tracker.SetPersistence(db)
tracker.Start(TaskKindOrganize, "unfinished", TaskUpdate{})
restarted := NewTaskTrackerService(zap.NewNop(), nil)
restarted.SetPersistence(db)
snapshot := restarted.Snapshot()
if len(snapshot.Recent) != 1 || snapshot.Recent[0].Status != TaskStatusInterrupted || snapshot.Recent[0].FinishedAt == nil {
t.Fatalf("abandoned task not marked interrupted: %#v", snapshot)
}
var running int64
if err := db.Model(&model.BackgroundTaskRecord{}).Where("status = ?", TaskStatusRunning).Count(&running).Error; err != nil || running != 0 {
t.Fatalf("stale running tasks: %d err=%v", running, err)
}
}
+10 -2
View File
@@ -170,8 +170,16 @@ func (s *TokenService) storeRefreshToken(ctx context.Context, rt *model.RefreshT
if err := s.repo.RefreshToken.Create(ctx, rt); err != nil {
return err
}
if err := s.repo.RefreshToken.RevokeOldestActiveByUserID(ctx, rt.UserID, s.maxActiveRefreshTokens(ctx)); err != nil && s.log != nil {
s.log.Warn("failed to enforce refresh token session limit", zap.String("user_id", rt.UserID), zap.Error(err))
// 「最大同时登录终端」只约束未绑定设备会话的旧客户端。
//
// 移动端每台设备有独立认证会话,刷新令牌归属该会话;如果仍按用户统一
// 裁剪活跃令牌,第 N+1 台设备登录(或任一设备刷新)就会把最早那台设备
// 的刷新令牌标记为已撤销,那台设备下次刷新收到 SESSION_REVOKED,被迫
// 重新登录。设备数量由设备策略单独约束,不能靠撤销刷新令牌实现。
if rt.AuthSessionID == "" {
if err := s.repo.RefreshToken.RevokeOldestActiveLegacyByUserID(ctx, rt.UserID, s.maxActiveRefreshTokens(ctx)); err != nil && s.log != nil {
s.log.Warn("failed to enforce refresh token session limit", zap.String("user_id", rt.UserID), zap.Error(err))
}
}
return nil
}
+28
View File
@@ -0,0 +1,28 @@
import { api, LONG_REQUEST_TIMEOUT } from './client'
export interface BackupInfo {
filename: string
size: number
created_at: string
automatic: boolean
}
export const backupAPI = {
list: () => api.get<{ items: BackupInfo[] }>('/admin/backups').then((r) => r.data.items ?? []),
create: () => api.post<BackupInfo>('/admin/backups', {}, { timeout: LONG_REQUEST_TIMEOUT }).then((r) => r.data),
remove: (filename: string) => api.delete('/admin/backups', { params: { filename } }),
restore: (filename: string) => api.post<{ message: string; restart_required: boolean }>('/admin/backups/restore', null, { params: { filename }, timeout: LONG_REQUEST_TIMEOUT }).then((r) => r.data),
download: async (filename: string) => {
const response = await api.get<Blob>('/admin/backups/download', {
params: { filename },
responseType: 'blob',
timeout: LONG_REQUEST_TIMEOUT,
})
const url = URL.createObjectURL(response.data)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
URL.revokeObjectURL(url)
},
}
+4
View File
@@ -10,6 +10,10 @@ export const embyAPI = {
deleteMount: (id: string) => api.delete(`/admin/emby/mounts/${id}`).then((r) => r.data),
reorderMounts: (ids: string[]) =>
api.put<{ ok: boolean }>('/admin/emby/mounts/reorder', { ids }).then((r) => r.data),
setAccountMountsEnabled: (accountId: string, enabled: boolean) =>
api
.put<{ ok: boolean; affected: number }>(`/admin/emby/accounts/${accountId}/mounts/enabled`, { enabled })
.then((r) => r.data),
listAccountViews: (accountId: string) =>
api.get<RemoteEmbyView[]>(`/admin/emby/accounts/${accountId}/views`).then((r) => r.data),
fullMountAccount: (accountId: string, proxy: boolean) =>
+14 -1
View File
@@ -262,7 +262,15 @@ export const mediaAPI = {
})
.then((r) => r.data),
searchPage: (q: string, page = 1, pageSize = 50, options?: { groupVersions?: boolean; groupSeries?: boolean }) =>
searchPage: (q: string, page = 1, pageSize = 50, options?: {
groupVersions?: boolean
groupSeries?: boolean
libraryId?: string
yearMin?: number
yearMax?: number
genres?: string[]
sort?: 'title_asc' | 'year_desc' | 'rating_desc'
}) =>
api
.get<MediaSearchPage>('/media', {
params: {
@@ -271,6 +279,11 @@ export const mediaAPI = {
page_size: pageSize,
group_versions: options?.groupVersions === false ? 0 : undefined,
...(options?.groupSeries ? { group_series: 1 } : {}),
...(options?.libraryId ? { library_id: options.libraryId } : {}),
...(options?.yearMin ? { year_min: options.yearMin } : {}),
...(options?.yearMax ? { year_max: options.yearMax } : {}),
...(options?.genres?.length ? { genre: options.genres } : {}),
...(options?.sort ? { sort: options.sort } : {}),
},
timeout: LONG_REQUEST_TIMEOUT,
})
+16
View File
@@ -0,0 +1,16 @@
import { api } from './client'
import type { Media } from '../types'
export type MediaQualityIssue = 'missing' | 'unmatched' | 'poster'
export interface MediaQualityRow {
issue: MediaQualityIssue
media: Media
}
export const qualityAPI = {
list: (issue: MediaQualityIssue, page = 1, pageSize = 50) =>
api.get<{ items: MediaQualityRow[]; total: number; page: number; page_size: number }>('/admin/quality', {
params: { issue, page, page_size: pageSize },
}).then((r) => r.data),
}
+2
View File
@@ -249,6 +249,8 @@ export const readerAPI = {
deleteSource: (id: string) => api.delete(`/reader/sources/${id}`),
debugSource: (id: string, key: string) =>
api.post<{ logs: string[] }>(`/reader/sources/${id}/debug`, { key }, longOpts).then((r) => r.data.logs),
smokeSource: (id: string, key: string) =>
api.post<{ logs: string[] }>(`/reader/sources/${id}/debug`, { key }, { timeout: 60_000 }).then((r) => r.data.logs),
// ── 书源登录与源变量 ──
// 取登录界面描述(loginUi 控件 + 已保存值 + 当前 Cookie 状态)
+27
View File
@@ -0,0 +1,27 @@
import { api } from './client'
export interface BackgroundTask {
id: string
kind: string
name: string
status: 'running' | 'completed' | 'failed' | 'interrupted' | string
stage?: string
source_path?: string
dest_path?: string
message?: string
error?: string
details?: string[]
metrics?: Record<string, number>
started_at: string
updated_at: string
finished_at?: string
}
export interface TaskSnapshot {
active: BackgroundTask[]
recent: BackgroundTask[]
}
export const tasksAPI = {
snapshot: () => api.get<TaskSnapshot>('/admin/tasks').then((r) => r.data),
}
+4
View File
@@ -50,6 +50,8 @@ const StrmUploadQueuePage = lazy(() =>
)
const ScraperQueuePage = lazy(() => import('./pages/ScraperQueuePage').then((m) => ({ default: m.ScraperQueuePage })))
const TaskQueuePage = lazy(() => import('./pages/TaskQueuePage').then((m) => ({ default: m.TaskQueuePage })))
const MediaQualityPage = lazy(() => import('./pages/MediaQualityPage').then((m) => ({ default: m.MediaQualityPage })))
const SetupWizardPage = lazy(() => import('./pages/SetupWizardPage').then((m) => ({ default: m.SetupWizardPage })))
// 常用页面的路由 chunk 空闲预取:应用加载完成后浏览器一空闲就把浏览路径
// (库列表/库详情/媒体详情/收藏/历史等)的 chunk 拉下来,
@@ -125,5 +127,7 @@ export const appRoutes: AppRoute[] = [
{ path: 'strm/uploads', element: <StrmUploadQueuePage />, adminOnly: true },
{ path: 'scraper/queue', element: <ScraperQueuePage />, adminOnly: true },
{ path: 'queue', element: <TaskQueuePage />, adminOnly: true },
{ path: 'quality', element: <MediaQualityPage />, adminOnly: true },
{ path: 'setup', element: <SetupWizardPage />, adminOnly: true },
{ path: 'admin', element: <AdminPage />, adminOnly: true },
]
+5
View File
@@ -89,6 +89,11 @@ export const MediaCard = memo(function MediaCard({
<span>{displayRating.toFixed(1)}</span>
</span>
)}
{media.availability_state === 'missing' && (
<span className="absolute left-2 top-2 rounded-lg border border-amber-300/30 bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-amber-300 shadow-sm">
文件失联
</span>
)}
{/* Premium Hover Overlay(CSS 过渡替代 framer-motion 逐卡动画实例) */}
<div className={`absolute inset-0 bg-gradient-to-t from-[#111827]/90 via-[#111827]/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-end ${
+9 -2
View File
@@ -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".
+6
View File
@@ -1,6 +1,8 @@
import type { LucideIcon } from 'lucide-react'
import {
Clock,
AlertTriangle,
ListChecks as SetupIcon,
FileOutput,
FolderOpen,
Heart,
@@ -41,6 +43,8 @@ export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
{ to: '/profile', label: '个人资料', icon: User },
{ to: '/libraries?from=admin', label: '媒体库', icon: Library },
{ to: '/queue', label: '任务队列', icon: ListChecks, adminOnly: true },
{ to: '/quality', label: '媒体质量', icon: AlertTriangle, adminOnly: true },
{ to: '/setup', label: '首次配置', icon: SetupIcon, adminOnly: true },
{ to: '/admin', label: '用户管理', icon: Users, adminOnly: true },
{ to: '/files', label: '文件管理', icon: FolderOpen, adminOnly: true },
{ to: '/emby-mount', label: 'Emby 挂载', icon: Tv, adminOnly: true },
@@ -159,6 +163,8 @@ export function resolveHeaderBack(pathname: string): HeaderBackTarget | null {
pathname === '/admin' ||
pathname === '/files' ||
pathname === '/queue' ||
pathname === '/quality' ||
pathname === '/setup' ||
pathname === '/emby-mount' ||
pathname === '/strm'
) {
+3 -2
View File
@@ -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])
}
+85
View File
@@ -0,0 +1,85 @@
import { useCallback, useEffect, useState } from 'react'
import { Activity, AlertTriangle, CheckCircle2, Clock3, RefreshCw } from 'lucide-react'
import { tasksAPI, type BackgroundTask, type TaskSnapshot } from '../api/tasks'
import { useWebSocket } from '../hooks/useWebSocket'
function statusLabel(status: string) {
switch (status) {
case 'running': return '运行中'
case 'completed': return '已完成'
case 'failed': return '失败'
case 'interrupted': return '进程中断'
default: return status
}
}
function TaskRow({ task }: { task: BackgroundTask }) {
const Icon = task.status === 'running' ? Activity : task.status === 'completed' ? CheckCircle2 : AlertTriangle
const tone = task.status === 'running' ? 'text-blue-500' : task.status === 'completed' ? 'text-emerald-500' : 'text-amber-600'
return (
<article className="rounded-xl border border-sand-200 bg-white/60 p-3">
<div className="flex items-start gap-3">
<Icon size={17} className={`mt-0.5 shrink-0 ${tone}`} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="break-words text-sm font-semibold text-ink-600">{task.name}</h3>
<span className={`text-xs font-semibold ${tone}`}>{statusLabel(task.status)}</span>
</div>
<p className="mt-1 text-xs text-ink-50">{task.message || task.stage || '—'} · {new Date(task.started_at).toLocaleString()}</p>
{task.error && <p className="mt-2 break-words text-xs text-red-500">{task.error}</p>}
{task.details && task.details.length > 0 && (
<details className="mt-2 text-xs text-ink-50"><summary className="cursor-pointer">查看执行详情({task.details.length})</summary>
<ul className="mt-2 space-y-1">{task.details.map((line, index) => <li key={`${index}-${line}`} className="break-words">{line}</li>)}</ul>
</details>
)}
</div>
</div>
</article>
)
}
export function BackgroundTasksPanel() {
const [snapshot, setSnapshot] = useState<TaskSnapshot>({ active: [], recent: [] })
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const refresh = useCallback(async () => {
setLoading(true)
try {
const result = await tasksAPI.snapshot()
setSnapshot({ active: result.active ?? [], recent: result.recent ?? [] })
setError('')
} catch {
setError('读取后台任务失败,请刷新重试')
} finally { setLoading(false) }
}, [])
useEffect(() => { void refresh() }, [refresh])
useWebSocket((topic, payload) => {
if (topic !== 'task' || !payload || typeof payload !== 'object') return
const task = payload as BackgroundTask
if (!task.id || !task.status) return
setSnapshot((current) => {
const active = current.active.filter((item) => item.id !== task.id)
const recent = current.recent.filter((item) => item.id !== task.id)
if (task.status === 'running') active.unshift(task)
else recent.unshift(task)
recent.sort((left, right) => right.started_at.localeCompare(left.started_at))
return { active, recent: recent.slice(0, 30) }
})
})
return (
<section className="glass-panel space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2"><Activity size={18} className="text-brand-500" /><h2 className="font-display text-xl font-bold text-ink-600">后台扫描与整理</h2></div>
<button type="button" className="btn-outline" onClick={() => void refresh()} disabled={loading}><RefreshCw size={14} />刷新</button>
</div>
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
{snapshot.active.length > 0 && <div className="space-y-2"><h3 className="text-xs font-bold uppercase tracking-wide text-ink-50">进行中</h3>{snapshot.active.map((task) => <TaskRow key={task.id} task={task} />)}</div>}
<div className="space-y-2">
<h3 className="flex items-center gap-1 text-xs font-bold uppercase tracking-wide text-ink-50"><Clock3 size={13} />最近任务</h3>
{snapshot.recent.length > 0 ? snapshot.recent.map((task) => <TaskRow key={task.id} task={task} />) : !error && <p className="rounded-xl border border-dashed border-sand-300 px-4 py-6 text-center text-sm text-ink-50">{loading ? '正在读取任务…' : '暂无历史任务'}</p>}
</div>
</section>
)
}
+157
View File
@@ -0,0 +1,157 @@
import { useCallback, useEffect, useState } from 'react'
import { Archive, Download, LoaderCircle, RefreshCw, RotateCcw, Trash2 } from 'lucide-react'
import toast from 'react-hot-toast'
import { backupAPI, type BackupInfo } from '../api/backup'
import { adminAPI } from '../api/admin'
import { confirmAction } from '../components/confirmAction'
function formatBytes(value: number) {
if (value < 1024) return `${value} B`
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`
return `${(value / (1024 * 1024)).toFixed(1)} MB`
}
export function BackupSettingsPanel() {
const [items, setItems] = useState<BackupInfo[]>([])
const [loading, setLoading] = useState(true)
const [creating, setCreating] = useState(false)
const [autoEnabled, setAutoEnabled] = useState(false)
const [autoTime, setAutoTime] = useState('03:00')
const [retention, setRetention] = useState('7')
const [savingPolicy, setSavingPolicy] = useState(false)
const refresh = useCallback(async () => {
setLoading(true)
try {
setItems(await backupAPI.list())
} catch {
toast.error('读取备份列表失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { void refresh() }, [refresh])
useEffect(() => {
void adminAPI.listSettings().then((settings) => {
const values = new Map(settings.map((setting) => [setting.key, setting.value]))
setAutoEnabled(['true', '1'].includes(values.get('backup.auto_enabled') ?? 'false'))
setAutoTime(values.get('backup.auto_time') || '03:00')
setRetention(values.get('backup.retention') || '7')
}).catch(() => toast.error('读取自动备份设置失败'))
}, [])
const savePolicy = async () => {
const count = Number(retention)
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(autoTime) || !Number.isInteger(count) || count < 1 || count > 365) {
toast.error('请填写有效时间,并将保留份数设为 1 到 365')
return
}
setSavingPolicy(true)
try {
await adminAPI.updateSetting('backup.auto_time', autoTime)
await adminAPI.updateSetting('backup.retention', String(count))
await adminAPI.updateSetting('backup.auto_enabled', String(autoEnabled))
toast.success('自动备份设置已保存')
} catch {
toast.error('保存自动备份设置失败')
} finally {
setSavingPolicy(false)
}
}
const create = async () => {
setCreating(true)
try {
const item = await backupAPI.create()
toast.success(`备份已创建:${item.filename}`)
await refresh()
} catch {
toast.error('创建备份失败,请查看服务日志')
} finally {
setCreating(false)
}
}
const remove = async (item: BackupInfo) => {
if (!await confirmAction({ title: '删除备份', message: `确定删除 ${item.filename}?此操作无法撤销。` })) return
try {
await backupAPI.remove(item.filename)
setItems((current) => current.filter((entry) => entry.filename !== item.filename))
toast.success('备份已删除')
} catch {
toast.error('删除备份失败')
}
}
const restore = async (item: BackupInfo) => {
const ok = await confirmAction({
title: '恢复数据库备份',
message: `将恢复 ${item.filename}。SQLite 会先校验并暂存备份,在重启时应用;PostgreSQL 会立即事务恢复。请先保存当前数据库备份,并在操作后重启 MeBox。`,
confirmText: '恢复备份',
danger: true,
})
if (!ok) return
try {
const result = await backupAPI.restore(item.filename)
toast.success(result.message, { duration: 8000 })
} catch (error) {
const message = (error as { response?: { data?: { error?: string } } })?.response?.data?.error
toast.error(message || '恢复失败,请查看服务日志')
}
}
return (
<section className="glass-panel space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Archive className="text-brand-500" size={20} />
<div>
<h2 className="font-display text-xl font-bold text-ink-600">数据库备份</h2>
<p className="text-xs text-ink-50">备份保存在服务器数据目录的 backups 文件夹中;请另行复制到其他设备。</p>
</div>
</div>
<div className="flex gap-2">
<button type="button" className="btn-outline" onClick={() => void refresh()} disabled={loading}><RefreshCw size={14} />刷新</button>
<button type="button" className="btn-primary" onClick={() => void create()} disabled={creating}>
{creating ? <LoaderCircle className="animate-spin" size={14} /> : <Archive size={14} />}立即备份
</button>
</div>
</div>
<div className="grid gap-3 rounded-xl border border-sand-200 p-4 sm:grid-cols-[1fr_auto_auto_auto] sm:items-end">
<label className="flex items-center gap-2 text-sm font-semibold text-ink-600">
<input type="checkbox" checked={autoEnabled} onChange={(event) => setAutoEnabled(event.target.checked)} />
每日自动备份
</label>
<label className="space-y-1 text-xs text-ink-50">执行时间
<input type="time" value={autoTime} onChange={(event) => setAutoTime(event.target.value)} className="block rounded-lg border border-sand-300 bg-white px-3 py-2 text-sm text-ink-600" />
</label>
<label className="space-y-1 text-xs text-ink-50">自动备份保留份数
<input type="number" min={1} max={365} value={retention} onChange={(event) => setRetention(event.target.value)} className="block w-28 rounded-lg border border-sand-300 bg-white px-3 py-2 text-sm text-ink-600" />
</label>
<button type="button" className="btn-outline" onClick={() => void savePolicy()} disabled={savingPolicy}>{savingPolicy ? '保存中…' : '保存策略'}</button>
</div>
{loading ? <div className="py-8 text-center text-sm text-ink-50">正在读取备份…</div> : items.length === 0 ? (
<div className="rounded-xl border border-dashed border-sand-300 px-4 py-8 text-center text-sm text-ink-50">还没有数据库备份</div>
) : (
<div className="divide-y divide-sand-200">
{items.map((item) => (
<div key={item.filename} className="flex flex-wrap items-center justify-between gap-3 py-3">
<div className="min-w-0">
<div className="break-all text-sm font-semibold text-ink-600">{item.filename}</div>
<div className="mt-1 text-xs text-ink-50">{item.automatic ? '自动备份' : '手动备份'} · {new Date(item.created_at).toLocaleString()} · {formatBytes(item.size)}</div>
</div>
<div className="flex flex-wrap gap-2">
<button type="button" className="btn-outline" onClick={() => void backupAPI.download(item.filename).catch(() => toast.error('下载备份失败'))}><Download size={14} />下载</button>
<button type="button" className="btn-outline" onClick={() => void restore(item)}><RotateCcw size={14} />恢复</button>
<button type="button" className="btn-outline text-red-500" onClick={() => void remove(item)}><Trash2 size={14} />删除</button>
</div>
</div>
))}
</div>
)}
</section>
)
}
+104 -5
View File
@@ -370,6 +370,8 @@ export function EmbyMountPage() {
const [accountDialog, setAccountDialog] = useState<StrmAccount | null | 'new'>(null)
const [mountDialogFor, setMountDialogFor] = useState<StrmAccount | null>(null)
const [testingID, setTestingID] = useState<string | null>(null)
const [togglingAccountID, setTogglingAccountID] = useState<string | null>(null)
const [bulkTogglingID, setBulkTogglingID] = useState<string | null>(null)
const [draggingId, setDraggingId] = useState<string | null>(null)
const [dragOverId, setDragOverId] = useState<string | null>(null)
const [reordering, setReordering] = useState(false)
@@ -504,6 +506,46 @@ export function EmbyMountPage() {
}
}
// 账号级总开关:停用后该服务器不再参与任何远程请求,其下挂载从媒体库中
// 立即隐藏(配置保留),用于远端宕机时先让首页恢复可用。
const toggleAccount = async (acct: StrmAccount) => {
const next = !acct.enabled
const acctMountCount = mounts.filter((m) => m.account_id === acct.id).length
if (!next && !(await confirmAction({
message: `停用 Emby 服务器「${acct.name}」?其下 ${acctMountCount} 个远程媒体库会立即从媒体库中隐藏,首页也不再请求该服务器;挂载配置与播放设置会保留,可随时重新启用。`,
confirmText: '停用账号',
}))) return
setTogglingAccountID(acct.id)
try {
await strmAPI.updateAccount(acct.id, { provider: acct.provider, enabled: next })
toast.success(next ? `已启用「${acct.name}」` : `已停用「${acct.name}」,远程媒体库已隐藏`)
await load()
} catch (err) {
toast.error(apiErrorMessage(err))
} finally {
setTogglingAccountID(null)
}
}
// 一键启停该账号下全部挂载(配置保留,只控制是否出现在媒体库中)。
const setAllMounts = async (acct: StrmAccount, enabled: boolean) => {
const acctMounts = mounts.filter((m) => m.account_id === acct.id)
if (!enabled && !(await confirmAction({
message: `停用「${acct.name}」下的全部 ${acctMounts.length} 个挂载?它们会立即从媒体库中隐藏,配置保留,可随时全部启用。`,
confirmText: '全部停用',
}))) return
setBulkTogglingID(acct.id)
try {
const res = await embyAPI.setAccountMountsEnabled(acct.id, enabled)
toast.success(enabled ? `已启用 ${res.affected} 个挂载` : `已停用 ${res.affected} 个挂载`)
await load()
} catch (err) {
toast.error(apiErrorMessage(err))
} finally {
setBulkTogglingID(null)
}
}
const removeMount = async (mount: EmbyMount) => {
if (!(await confirmAction({ message: `取消挂载「${mount.remote_view_name || mount.name}」?媒体库列表中将不再显示。`, confirmText: '取消挂载' }))) return
try {
@@ -566,26 +608,55 @@ export function EmbyMountPage() {
) : (
accounts.map((acct) => {
const acctMounts = mounts.filter((m) => m.account_id === acct.id)
const anyMountEnabled = acctMounts.some((m) => m.enabled)
return (
<div key={acct.id} className="overflow-hidden rounded-3xl border border-sand-200 bg-white shadow-card">
<div
key={acct.id}
className={`overflow-hidden rounded-3xl border bg-white shadow-card ${
acct.enabled ? 'border-sand-200' : 'border-gray-200 opacity-80'
}`}
>
{/* 账号头部 */}
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-sand-100 bg-gradient-to-r from-brand-50/60 to-transparent px-5 py-4">
<div className="flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-brand-100 text-brand-600">
<span className={`flex h-10 w-10 items-center justify-center rounded-2xl ${acct.enabled ? 'bg-brand-100 text-brand-600' : 'bg-gray-100 text-sand-500'}`}>
<Server size={20} />
</span>
<div>
<p className="font-display text-lg font-bold text-ink-600">{acct.name}</p>
<p className="font-display text-lg font-bold text-ink-600">
{acct.name}
{!acct.enabled && (
<span className="ml-2 rounded-full bg-gray-200 px-2 py-0.5 align-middle text-[11px] font-semibold text-sand-500">
已停用
</span>
)}
</p>
<p className="text-xs text-sand-500">
{acct.has_credential ? '凭据已配置' : '待补全凭据'}
{acct.emby_lines && acct.emby_lines.length > 0
? ` · ${acct.emby_lines.length} 条线路`
: ''}
{acct.last_test_result ? ` · 最近测试:${acct.last_test_ok ? '正常' : acct.last_test_result}` : ''}
{!acct.enabled ? ' · 不会发起远程请求' : ''}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => toggleAccount(acct)}
disabled={togglingAccountID === acct.id}
className={`iconButtonCls ${acct.enabled ? '!text-amber-600 hover:!bg-amber-50' : '!text-green-600 hover:!bg-green-50'}`}
title={acct.enabled ? '停用该服务器:其下挂载全部从媒体库隐藏' : '重新启用该服务器'}
>
{togglingAccountID === acct.id ? (
<Loader2 size={13} className="animate-spin" />
) : acct.enabled ? (
<PowerOff size={13} />
) : (
<Power size={13} />
)}
{acct.enabled ? '停用账号' : '启用账号'}
</button>
<button onClick={() => testAccount(acct)} disabled={testingID === acct.id} className="iconButtonCls">
{testingID === acct.id ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
测试
@@ -644,6 +715,7 @@ export function EmbyMountPage() {
<p className="text-xs text-sand-500">
{m.collection_type || 'mixed'}
{m.proxy_play ? ' · 本机代理播放' : ' · 直连播放'}
{!m.enabled ? ' · 已停用' : ''}
</p>
</div>
<div className="flex items-center gap-1">
@@ -687,14 +759,41 @@ export function EmbyMountPage() {
</div>
)}
<div className="mt-4 flex flex-wrap gap-2">
<button onClick={() => setMountDialogFor(acct)} className="btn-outline">
<button
onClick={() => setMountDialogFor(acct)}
disabled={!acct.enabled}
className="btn-outline disabled:cursor-not-allowed disabled:opacity-40"
title={acct.enabled ? undefined : '账号已停用,启用后才能挂载新的媒体库'}
>
<Plus size={14} />
选择挂载媒体库
</button>
<button onClick={() => fullMount(acct)} className="btn-outline">
<button
onClick={() => fullMount(acct)}
disabled={!acct.enabled}
className="btn-outline disabled:cursor-not-allowed disabled:opacity-40"
title={acct.enabled ? undefined : '账号已停用,启用后才能挂载新的媒体库'}
>
<Check size={14} />
全量挂载
</button>
{acctMounts.length > 0 && (
<button
onClick={() => setAllMounts(acct, !anyMountEnabled)}
disabled={bulkTogglingID === acct.id}
className="btn-outline disabled:cursor-not-allowed disabled:opacity-40"
title={anyMountEnabled ? '把该账号下全部挂载从媒体库中隐藏(配置保留)' : '恢复该账号下全部挂载'}
>
{bulkTogglingID === acct.id ? (
<Loader2 size={14} className="animate-spin" />
) : anyMountEnabled ? (
<PowerOff size={14} />
) : (
<Power size={14} />
)}
{anyMountEnabled ? '全部停用' : '全部启用'}
</button>
)}
</div>
</div>
</div>
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { AlertTriangle, ChevronLeft, ChevronRight, ImageOff, Search, WifiOff } from 'lucide-react'
import { qualityAPI, type MediaQualityIssue, type MediaQualityRow } from '../api/quality'
const tabs: { key: MediaQualityIssue; label: string; icon: typeof AlertTriangle }[] = [
{ key: 'unmatched', label: '未匹配元数据', icon: Search },
{ key: 'poster', label: '缺少海报', icon: ImageOff },
{ key: 'missing', label: '文件失联', icon: WifiOff },
]
const PAGE_SIZE = 50
export function MediaQualityPage() {
const [params, setParams] = useSearchParams()
const rawIssue = params.get('issue')
const issue: MediaQualityIssue = rawIssue === 'poster' || rawIssue === 'missing' ? rawIssue : 'unmatched'
const page = Math.max(1, Number.parseInt(params.get('page') ?? '1', 10) || 1)
const [items, setItems] = useState<MediaQualityRow[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [revision, setRevision] = useState(0)
useEffect(() => {
let cancelled = false
setLoading(true)
setError('')
void qualityAPI.list(issue, page, PAGE_SIZE).then((result) => {
if (!cancelled) { setItems(result.items ?? []); setTotal(result.total ?? 0) }
}).catch(() => {
if (!cancelled) setError('读取质量检查结果失败,请重试')
}).finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [issue, page, revision])
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const changePage = (nextPage: number) => setParams((current) => {
const next = new URLSearchParams(current)
if (nextPage <= 1) next.delete('page')
else next.set('page', String(nextPage))
return next
})
return (
<div className="space-y-5">
<header>
<h1 className="font-display text-3xl font-bold text-ink-600">媒体质量检查</h1>
<p className="mt-1 text-sm text-ink-50">集中查看待匹配、缺少海报和文件失联的媒体条目。</p>
</header>
<nav className="flex flex-wrap gap-2 border-b border-sand-200 pb-3">
{tabs.map((tab) => {
const Icon = tab.icon
return <button key={tab.key} type="button" onClick={() => setParams(tab.key === 'unmatched' ? {} : { issue: tab.key })} className={`inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-semibold ${issue === tab.key ? 'bg-brand-500 text-white' : 'text-ink-100 hover:bg-brand-50'}`}><Icon size={15} />{tab.label}</button>
})}
<span className="ml-auto self-center text-sm text-ink-50">{loading ? '正在检查…' : error ? '读取失败' : `共 ${total} 项`}</span>
</nav>
{error && <div role="alert" className="glass-panel text-sm text-red-500">{error}<button type="button" className="ml-3 underline" onClick={() => setRevision((value) => value + 1)}>重试</button></div>}
{!error && !loading && (items.length === 0 ? <div className="glass-panel py-12 text-center text-sm text-ink-50">{page > 1 ? '本页没有结果,请返回上一页。' : '当前分类没有待处理媒体。'}</div> : (
<div className="divide-y divide-sand-200 overflow-hidden rounded-2xl border border-sand-200 bg-white/60">
{items.map(({ media }) => <article key={media.id} className="flex flex-wrap items-center justify-between gap-3 p-4">
<div className="min-w-0"><Link to={`/media/${media.id}`} className="break-words font-semibold text-ink-600 hover:text-brand-500">{media.title}</Link><p className="mt-1 break-all text-xs text-ink-50">{media.library_name || '未知媒体库'} · {media.path}</p><p className="mt-1 text-xs text-ink-50">{media.year || '年份未知'}{media.scrape_status ? ` · ${media.scrape_status}` : ''}{media.missing_since ? ` · 失联于 ${new Date(media.missing_since).toLocaleString()}` : ''}</p></div>
<Link to={`/media/${media.id}`} className="btn-outline">查看并处理</Link>
</article>)}
</div>
))}
{!loading && !error && (totalPages > 1 || page > 1) && <nav aria-label="媒体质量检查分页" className="flex items-center justify-center gap-4 py-3 text-sm">
<button type="button" className="btn-outline" disabled={page <= 1} onClick={() => changePage(page - 1)}><ChevronLeft size={15} />上一页</button>
<span className="text-ink-50">第 {page} / {totalPages} 页</span>
<button type="button" className="btn-outline" disabled={page >= totalPages} onClick={() => changePage(page + 1)}>下一页<ChevronRight size={15} /></button>
</nav>}
</div>
)
}
+77 -12
View File
@@ -1,14 +1,16 @@
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useLocation, useSearchParams } from 'react-router-dom'
import { ChevronLeft, ChevronRight, LoaderCircle, RefreshCw, Search } from 'lucide-react'
import { mediaAPI, type MediaSearchPage as SearchPageData } from '../api/library'
import { libraryAPI } from '../api/library'
import { MediaCard } from '../components/MediaCard'
import type { Library } from '../types'
import { PageBackButton } from '../components/PageBackButton'
import { normalizeListPosition } from '../hooks/useListPositionMemory'
import { useAuthStore } from '../stores/auth'
import { usePlayProfileStore } from '../stores/playProfile'
import { favouriteMediaLink, mediaSearchLink } from '../utils/mediaNavigation'
import { favouriteMediaLink } from '../utils/mediaNavigation'
const PAGE_SIZE = 48
const CACHE_TTL_MS = 5 * 60_000
@@ -29,40 +31,72 @@ export function MediaSearchPage() {
const [params, setParams] = useSearchParams()
const query = (params.get('q') ?? '').trim()
const page = normalizeListPosition(params.get('page'))
const filters = {
libraryId: params.get('library_id') ?? '',
yearMin: params.get('year_min') ?? '',
yearMax: params.get('year_max') ?? '',
genres: params.get('genre') ?? '',
sort: params.get('sort') ?? '',
}
const userId = useAuthStore((s) => s.user?.id)
const profileId = usePlayProfileStore((s) => s.activeProfileId)
const cacheKey = JSON.stringify([userId, profileId, query, page])
const cacheKey = JSON.stringify([userId, profileId, query, page, filters])
const changePage = (next: number) => {
const nextParams = new URLSearchParams({ q: query })
const nextParams = new URLSearchParams(params)
nextParams.set('q', query)
if (next > 1) nextParams.set('page', String(next))
else nextParams.delete('page')
setParams(nextParams)
}
const changeFilter = (key: keyof typeof filters, value: string) => {
const next = new URLSearchParams(params)
const queryKey = { libraryId: 'library_id', yearMin: 'year_min', yearMax: 'year_max', genres: 'genre', sort: 'sort' }[key]
if (value.trim()) next.set(queryKey, value)
else next.delete(queryKey)
next.delete('page')
setParams(next, { replace: true })
}
return (
<MediaSearchResults
key={cacheKey}
key={JSON.stringify([userId, profileId])}
cacheKey={cacheKey}
query={query}
page={page}
onPageChange={changePage}
filters={filters}
onFilterChange={changeFilter}
/>
)
}
function MediaSearchResults({ cacheKey, query, page, onPageChange }: {
function MediaSearchResults({ cacheKey, query, page, onPageChange, filters, onFilterChange }: {
cacheKey: string
query: string
page: number
onPageChange: (page: number) => void
filters: { libraryId: string; yearMin: string; yearMax: string; genres: string; sort: string }
onFilterChange: (key: keyof { libraryId: string; yearMin: string; yearMax: string; genres: string; sort: string }, value: string) => void
}) {
const location = useLocation()
const [data, setData] = useState(() => readCachedPage(cacheKey))
const [loading, setLoading] = useState(() => !!query && !readCachedPage(cacheKey))
const [error, setError] = useState('')
const [revision, setRevision] = useState(0)
const [libraries, setLibraries] = useState<Library[]>([])
useEffect(() => { void libraryAPI.list().then(setLibraries).catch(() => setLibraries([])) }, [])
useEffect(() => {
if (!query) return
if (!query) {
setData(null)
setLoading(false)
setError('')
return
}
setError('')
const cached = readCachedPage(cacheKey)
if (cached) {
setData(cached)
@@ -72,8 +106,15 @@ function MediaSearchResults({ cacheKey, query, page, onPageChange }: {
let cancelled = false
setLoading(true)
setError('')
mediaAPI.searchPage(query, page, PAGE_SIZE, { groupSeries: true })
setData(null)
mediaAPI.searchPage(query, page, PAGE_SIZE, {
groupSeries: true,
libraryId: filters.libraryId || undefined,
yearMin: Number(filters.yearMin) || undefined,
yearMax: Number(filters.yearMax) || undefined,
genres: filters.genres.split(/[,,]/).map((value) => value.trim()).filter(Boolean),
sort: filters.sort === 'title_asc' || filters.sort === 'year_desc' || filters.sort === 'rating_desc' ? filters.sort : undefined,
})
.then((result) => {
if (cancelled) return
// 限制缓存大小,避免长时间搜索大量关键词后占用过多内存。
@@ -89,7 +130,7 @@ function MediaSearchResults({ cacheKey, query, page, onPageChange }: {
})
return () => { cancelled = true }
}, [cacheKey, query, page, revision])
}, [cacheKey, query, page, revision, filters.libraryId, filters.yearMin, filters.yearMax, filters.genres, filters.sort])
const refresh = () => {
pageCache.delete(cacheKey)
@@ -99,7 +140,7 @@ function MediaSearchResults({ cacheKey, query, page, onPageChange }: {
const items = data?.items ?? []
const total = data?.total ?? items.length
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const from = mediaSearchLink(query, page)
const from = location.pathname + location.search
return (
<div className="space-y-6">
@@ -126,6 +167,30 @@ function MediaSearchResults({ cacheKey, query, page, onPageChange }: {
)}
</div>
{query && (
<div className="grid gap-3 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-4 sm:grid-cols-2 lg:grid-cols-5">
<label className="space-y-1 text-xs text-[var(--app-muted)]">媒体库
<select value={filters.libraryId} onChange={(event) => onFilterChange('libraryId', event.target.value)} className="block w-full rounded-lg border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-sm text-[var(--app-text)]">
<option value="">所有媒体库</option>{libraries.map((library) => <option key={library.id} value={library.id}>{library.name}</option>)}
</select>
</label>
<label className="space-y-1 text-xs text-[var(--app-muted)]">年份从
<input type="number" min={1870} max={2200} value={filters.yearMin} onChange={(event) => onFilterChange('yearMin', event.target.value)} placeholder="不限" className="block w-full rounded-lg border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-sm text-[var(--app-text)]" />
</label>
<label className="space-y-1 text-xs text-[var(--app-muted)]">年份至
<input type="number" min={1870} max={2200} value={filters.yearMax} onChange={(event) => onFilterChange('yearMax', event.target.value)} placeholder="不限" className="block w-full rounded-lg border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-sm text-[var(--app-text)]" />
</label>
<label className="space-y-1 text-xs text-[var(--app-muted)]">类型(逗号分隔)
<input value={filters.genres} onChange={(event) => onFilterChange('genres', event.target.value)} placeholder="动画,科幻" className="block w-full rounded-lg border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-sm text-[var(--app-text)]" />
</label>
<label className="space-y-1 text-xs text-[var(--app-muted)]">排序
<select value={filters.sort} onChange={(event) => onFilterChange('sort', event.target.value)} className="block w-full rounded-lg border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-sm text-[var(--app-text)]">
<option value="">相关度 / 最近添加</option><option value="title_asc">名称 A–Z</option><option value="year_desc">年份从新到旧</option><option value="rating_desc">评分从高到低</option>
</select>
</label>
</div>
)}
{loading && (
<div role="status" className="flex items-center justify-center gap-2 py-20 text-sm text-[var(--app-muted)]">
<LoaderCircle size={18} className="animate-spin text-brand-500" />
@@ -148,7 +213,7 @@ function MediaSearchResults({ cacheKey, query, page, onPageChange }: {
</div>
)}
{!loading && items.length > 0 && (
{!loading && !error && query && items.length > 0 && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{items.map((item) => (
<MediaCard key={item.id} media={item} showLibrary linkTo={favouriteMediaLink(item)} linkState={{ from }} />
+95 -1
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import type Hls from 'hls.js'
import toast from 'react-hot-toast'
import { Activity, Clipboard, X } from 'lucide-react'
import { mediaAPI, libraryAPI } from '../api/library'
import { cloudHlsURL, hlsURL, postPlaybackProgressKeepalive, stopHLSJob, streamURL } from '../api/client'
@@ -80,6 +81,8 @@ type PlaybackProgressSession = {
sequence: number
}
type PlayerDiagnosticEvent = { at: string; phase: string; mode: string; message: string }
// 自动跳过片头后,「已跳过 · 撤销」提示停留的时长。
const SKIP_NOTICE_MS = 6000
@@ -148,6 +151,10 @@ export function PlayerPage() {
vr360DirectProxyRef.current = vr360DirectProxy
}, [vr360DirectProxy])
const [mode, setMode] = useState<PlayerMode>('direct')
const diagnosticModeRef = useRef(mode)
useEffect(() => { diagnosticModeRef.current = mode }, [mode])
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false)
const [diagnosticEvents, setDiagnosticEvents] = useState<PlayerDiagnosticEvent[]>([])
const [subs, setSubs] = useState<SubtitleTrack[]>([])
const [subtitleIndex, setSubtitleIndex] = useState<number>(0)
const authUser = useAuthStore((state) => state.user)
@@ -498,6 +505,8 @@ export function PlayerPage() {
// 切换视频时重置媒体与弹幕状态,确保新视频自动重新识别并加载弹幕
useEffect(() => {
setMedia(null)
setDiagnosticEvents([])
setDiagnosticsOpen(false)
setLoadError('')
vr360TouchedRef.current = false
vr360TextureRetryUsedRef.current = false
@@ -556,6 +565,33 @@ export function PlayerPage() {
setMode(next)
}, [])
const recordDiagnostic = useCallback((phase: string, message: string) => {
const safeMessage = message
.replace(/https?:\/\/[^\s"'<>]+/gi, '[URL]')
.replace(/\b(token|auth|api[_-]?key|password)=([^\s&]+)/gi, '$1=[redacted]')
setDiagnosticEvents((current) => [...current.slice(-39), {
at: new Date().toISOString(), phase, mode: diagnosticModeRef.current, message: safeMessage.slice(0, 300),
}])
}, [])
useEffect(() => {
const video = ref.current
if (!video) return
const bindings: Array<[string, string]> = [
['loadedmetadata', '已读取媒体信息'],
['playing', '开始播放'],
['waiting', '等待缓冲'],
['stalled', '媒体数据停止到达'],
['seeked', '定位完成'],
]
const listeners = bindings.map(([event, message]) => {
const listener = () => recordDiagnostic(event === 'waiting' || event === 'stalled' ? 'buffer' : 'play', message)
video.addEventListener(event, listener)
return [event, listener] as const
})
return () => { for (const [event, listener] of listeners) video.removeEventListener(event, listener) }
}, [media?.id, recordDiagnostic])
const clearFallbackTimer = useCallback(() => {
if (fallbackTimerRef.current) {
clearTimeout(fallbackTimerRef.current)
@@ -569,9 +605,10 @@ export function PlayerPage() {
* 只有画面没有声音」)不走这里,它们播起来之后依然成立。
*/
const showPlayFailure = useCallback((message: string) => {
recordDiagnostic('error', message)
transientPlayErrorRef.current = message
setPlayerError(message)
}, [])
}, [recordDiagnostic])
const clearPlayFailure = useCallback(() => {
const stale = transientPlayErrorRef.current
@@ -1199,6 +1236,7 @@ export function PlayerPage() {
if (Date.now() < directSeekGraceUntilRef.current) return
const current = ref.current
if (current && current.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return
recordDiagnostic('fallback', `直连失败,原因:${reason}`)
if (isRemoteEmbyID(mediaRef.current?.id) || isDirectStreamMedia(mediaRef.current)) {
const copy = directPlayFailureCopy('remote-emby', reason)
showPlayFailure(copy.banner)
@@ -1234,6 +1272,7 @@ export function PlayerPage() {
directOnly,
hlsUnavailable,
playbackInfo,
recordDiagnostic,
selectedQuality,
setPlaybackMode,
showPlayFailure,
@@ -2381,6 +2420,32 @@ export function PlayerPage() {
// 桌面端与横屏手机保持原来的全屏居中沉浸式布局。
const isMobileTheater = useIsMobileTheater()
const copyDiagnostics = async () => {
const video = ref.current
const snapshot = {
title: media?.title || '',
sourceType: isRemoteEmbyID(media?.id) ? 'remote_emby' : isStrmMedia(media) ? 'strm' : 'local',
mode,
hlsSource: mode === 'hls' ? hlsSource : undefined,
quality: selectedQuality || undefined,
browser: navigator.userAgent,
video: video ? {
errorCode: video.error?.code ?? null,
readyState: video.readyState,
networkState: video.networkState,
currentTime: Number.isFinite(video.currentTime) ? Math.round(video.currentTime) : null,
duration: Number.isFinite(video.duration) ? Math.round(video.duration) : null,
} : null,
events: diagnosticEvents,
}
try {
await navigator.clipboard.writeText(JSON.stringify(snapshot, null, 2))
toast.success('播放诊断信息已复制(未包含播放地址或登录凭证)')
} catch {
toast.error('复制失败,请检查浏览器剪贴板权限')
}
}
return (
<div className="relative flex h-full w-full flex-1 flex-col overflow-hidden bg-black">
<PlayerTopBar
@@ -2543,6 +2608,35 @@ export function PlayerPage() {
}
/>
) : null}
<button
type="button"
onClick={() => setDiagnosticsOpen((open) => !open)}
className="fixed bottom-5 right-4 z-[80] inline-flex items-center gap-2 rounded-full border border-white/15 bg-black/75 px-3 py-2 text-xs font-semibold text-white shadow-lg backdrop-blur hover:bg-black/90"
title="播放诊断"
aria-label="播放诊断"
>
<Activity size={15} />诊断
</button>
{diagnosticsOpen && (
<aside className="fixed bottom-16 right-4 z-[80] max-h-[70vh] w-[min(92vw,420px)] overflow-hidden rounded-2xl border border-white/15 bg-[#111827]/95 text-white shadow-2xl backdrop-blur">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
<div><h2 className="text-sm font-bold">播放诊断</h2><p className="mt-1 text-[11px] text-white/60">记录最近 40 条本地播放事件</p></div>
<div className="flex gap-2">
<button type="button" onClick={() => void copyDiagnostics()} className="inline-flex items-center gap-1 rounded-lg bg-white/10 px-2.5 py-1.5 text-xs hover:bg-white/15"><Clipboard size={13} />复制</button>
<button type="button" onClick={() => setDiagnosticsOpen(false)} className="rounded-lg p-1.5 hover:bg-white/10" aria-label="关闭"><X size={16} /></button>
</div>
</div>
<div className="max-h-[55vh] space-y-2 overflow-y-auto p-3">
<div className="rounded-lg bg-white/5 px-3 py-2 text-xs text-white/70">模式:{mode === 'hls' ? `HLS / ${hlsSource}` : '直连'} · 来源:{isRemoteEmbyID(media?.id) ? '远程 Emby' : isStrmMedia(media) ? 'STRM' : '本地文件'}</div>
{diagnosticEvents.length === 0 ? <p className="py-6 text-center text-xs text-white/50">等待播放事件…</p> : [...diagnosticEvents].reverse().map((event, index) => (
<div key={`${event.at}-${index}`} className="rounded-lg bg-white/5 px-3 py-2">
<div className="flex justify-between gap-2 text-[10px] text-white/45"><span>{event.phase} · {event.mode}</span><time>{new Date(event.at).toLocaleTimeString()}</time></div>
<p className="mt-1 break-words text-xs">{event.message}</p>
</div>
))}
</div>
</aside>
)}
</div>
)
}
+2
View File
@@ -11,6 +11,7 @@ import { FFToolsPanel } from '../components/FFToolsPanel'
import { AboutSettingsPanel } from './AboutSettingsPanel'
import { AdultSettingsPanel } from './AdultSettingsPanel'
import { DatabaseSettingsPanel } from './DatabaseSettingsPanel'
import { BackupSettingsPanel } from './BackupSettingsPanel'
import { RecognitionWordsPanel } from './RecognitionWordsPanel'
import { SettingRow } from './SettingsRow'
import { TelegramNotifyPanel } from './TelegramNotifyPanel'
@@ -172,6 +173,7 @@ export function SettingsPage() {
{!loading && (
<div className="space-y-4">
{group.key === 'database' && <DatabaseSettingsPanel />}
{group.key === 'backup' && <BackupSettingsPanel />}
{group.key === 'api-configs' && <APIConfigsPanel />}
{group.key === 'recognition-words' && <RecognitionWordsPanel />}
{group.key === 'adult' && <AdultSettingsPanel />}
+100
View File
@@ -0,0 +1,100 @@
import { useEffect, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { CheckCircle2, Circle, Database, Film, KeyRound, Server, Wrench } from 'lucide-react'
import { adminAPI, type DatabaseStatus } from '../api/admin'
import { apiConfigsAPI, type APIConfig } from '../api/api_configs'
import { libraryAPI, mediaAPI } from '../api/library'
import { toolsAPI, type FFmpegToolsStatus } from '../api/tools'
import type { Library } from '../types'
import type { SeriesCard } from '../utils/groupSeries'
type CheckResult = { ok: boolean; message: string }
type SetupStepProps = { done: boolean; title: string; detail: string; icon: typeof Database; children?: ReactNode }
function SetupStep({ done, title, detail, icon: Icon, children }: SetupStepProps) {
return (
<section className="glass-panel space-y-3">
<div className="flex items-start gap-3"><div className="mt-0.5 text-brand-500"><Icon size={20} /></div><div className="min-w-0 flex-1"><div className="flex items-center gap-2"><h2 className="font-display text-lg font-bold text-ink-600">{title}</h2>{done ? <CheckCircle2 size={16} className="text-emerald-500" /> : <Circle size={16} className="text-ink-50" />}</div><p className="mt-1 text-sm text-ink-50">{detail}</p></div></div>
{children}
</section>
)
}
export function SetupWizardPage() {
const [database, setDatabase] = useState<DatabaseStatus | null>(null)
const [libraries, setLibraries] = useState<Library[]>([])
const [sources, setSources] = useState<APIConfig[]>([])
const [ffmpeg, setFFmpeg] = useState<FFmpegToolsStatus | null>(null)
const [checks, setChecks] = useState<Record<string, CheckResult>>({})
const [busy, setBusy] = useState('')
const [recent, setRecent] = useState<SeriesCard | null>(null)
const [playbackVerified, setPlaybackVerified] = useState(false)
const refresh = async () => {
setBusy('refresh')
try {
const [db, libs, configs, tools, media] = await Promise.allSettled([
adminAPI.getDatabaseStatus(), libraryAPI.list(), apiConfigsAPI.list(), toolsAPI.ffToolsStatus(), mediaAPI.recent(1),
])
if (db.status === 'fulfilled') setDatabase(db.value)
if (libs.status === 'fulfilled') setLibraries(libs.value)
if (configs.status === 'fulfilled') setSources(configs.value)
if (tools.status === 'fulfilled') setFFmpeg(tools.value)
if (media.status === 'fulfilled') setRecent(media.value[0] ?? null)
const failed = [db, libs, configs, tools, media].some((result) => result.status === 'rejected')
setChecks((value) => ({ ...value, load: { ok: !failed, message: failed ? '部分检查项读取失败,请刷新状态后重试' : '' } }))
} finally {
setBusy('')
}
}
useEffect(() => { void refresh() }, [])
const testSources = async () => {
const enabled = sources.filter((source) => source.enabled)
if (!enabled.length) {
setChecks((value) => ({ ...value, sources: { ok: false, message: '尚未启用元数据源,可先前往管理来源配置' } }))
return
}
setBusy('sources')
const results = await Promise.all(enabled.map(async (source) => {
try { const result = await apiConfigsAPI.test(source.provider); return { name: source.provider, ok: !result.error, message: result.error || result.result } }
catch { return { name: source.provider, ok: false, message: '连接测试失败' } }
}))
const failed = results.filter((result) => !result.ok)
setChecks((value) => ({ ...value, sources: { ok: failed.length === 0, message: failed.length ? `${failed.length}/${results.length} 个来源测试失败:${failed.map((result) => `${result.name}(${result.message})`).join('、')}` : `已完成 ${results.length} 个来源的连接测试` } }))
setBusy('')
}
const scanLibrary = async (library: Library) => {
setBusy(library.id)
try {
const result = await libraryAPI.scan(library.id)
setChecks((value) => ({ ...value, [`scan:${library.id}`]: { ok: !result.queued, message: result.message || (result.queued ? '扫描已提交,请在任务队列确认是否完成' : `扫描完成:新增 ${result.added ?? 0} 项`) } }))
} catch {
setChecks((value) => ({ ...value, [`scan:${library.id}`]: { ok: false, message: '扫描失败,请检查容器内路径和目录权限' } }))
} finally { setBusy('') }
}
return (
<div className="mx-auto max-w-4xl space-y-5">
<header className="flex flex-wrap items-end justify-between gap-3"><div><h1 className="font-display text-3xl font-bold text-ink-600">首次配置向导</h1><p className="mt-1 text-sm text-ink-50">逐项检查配置,完成后可以随时回到对应设置调整。</p></div><button type="button" className="btn-outline" disabled={busy !== ''} onClick={() => void refresh()}>刷新状态</button></header>
{checks.load && !checks.load.ok && <p role="alert" className="glass-panel text-sm text-red-500">{checks.load.message}</p>}
<SetupStep done={Boolean(database)} title="数据库" detail={database ? `${database.type.toUpperCase()} · ${database.open_conns} 个连接` : '正在检查数据库'} icon={Database} />
<SetupStep done={Boolean(ffmpeg?.ffmpeg?.installed && ffmpeg?.ffprobe?.installed)} title="播放工具" detail={ffmpeg?.ffmpeg?.installed ? ffmpeg.ffmpeg.version || 'FFmpeg 已安装' : 'FFmpeg 未安装,浏览器不兼容的媒体无法转码'} icon={Wrench}>
{!ffmpeg?.ffmpeg?.installed && <Link to="/settings?group=general" className="btn-outline inline-flex">前往安装 FFmpeg</Link>}
</SetupStep>
<SetupStep done={Boolean(checks.sources?.ok)} title="元数据源" detail={checks.sources?.message || `${sources.filter((source) => source.enabled).length} 个已启用来源`} icon={KeyRound}>
<div className="flex flex-wrap items-center gap-3"><button type="button" className="btn-outline" disabled={busy !== ''} onClick={() => void testSources()}>{busy === 'sources' ? '测试中…' : '测试已配置来源'}</button><Link to="/settings?group=api-configs" className="text-sm text-brand-500">管理来源</Link></div>
</SetupStep>
<SetupStep done={libraries.length > 0 && libraries.every((library) => library.is_remote_emby || checks[`scan:${library.id}`]?.ok)} title="媒体库与目录" detail={libraries.length ? `已配置 ${libraries.length} 个媒体库;扫描会遍历所选本地库的全部目录,请先确认 Compose 中挂载了正确的容器路径` : '请先创建媒体库并填写容器内路径'} icon={Server}>
{libraries.length ? <div className="space-y-2">{libraries.map((library) => <div key={library.id} className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-sand-200 p-3"><div className="min-w-0"><p className="text-sm font-semibold text-ink-600">{library.name}{library.is_remote_emby && <span className="ml-2 text-xs font-normal text-ink-50">远程只读</span>}</p><p className="break-all text-xs text-ink-50">{library.path}</p>{checks[`scan:${library.id}`] && <p className={`mt-1 text-xs ${checks[`scan:${library.id}`].ok ? 'text-emerald-600' : 'text-red-500'}`}>{checks[`scan:${library.id}`].message}</p>}</div>{!library.is_remote_emby && <button type="button" className="btn-outline" disabled={busy !== ''} onClick={() => void scanLibrary(library)}>{busy === library.id ? '提交中…' : '扫描整个媒体库'}</button>}</div>)}</div> : <Link to="/libraries" className="btn-outline inline-flex">创建媒体库</Link>}
</SetupStep>
<SetupStep done={playbackVerified} title="验证播放" detail={recent ? `最近入库:${recent.rep.title};播放后请确认画面和声音正常` : '先完成媒体库扫描,找到一个可播放条目'} icon={Film}>
{recent && <Link to={`/play/${recent.linkMedia?.id ?? recent.rep.id}`} className="btn-primary inline-flex">播放测试片</Link>}
{recent && <label className="ml-3 inline-flex items-center gap-2 text-sm text-ink-100"><input type="checkbox" checked={playbackVerified} onChange={(event) => setPlaybackVerified(event.target.checked)} />已确认播放正常</label>}
</SetupStep>
<div className="flex flex-wrap gap-3"><Link to="/queue" className="btn-outline">查看任务进度</Link><Link to="/settings" className="btn-outline">打开系统设置</Link></div>
</div>
)
}
+3 -1
View File
@@ -7,6 +7,7 @@ import { scraperAPI } from '../api/scraper'
import { strmAPI } from '../api/strm'
import { ScraperQueuePage } from './ScraperQueuePage'
import { StrmQueuePanel } from './StrmQueuePage'
import { BackgroundTasksPanel } from './BackgroundTasksPanel'
type QueueType = 'all' | 'scrape' | 'download' | 'upload'
@@ -73,6 +74,7 @@ export function TaskQueuePage() {
return (
<div className="space-y-5">
<BackgroundTasksPanel />
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">任务队列</h1>
@@ -145,4 +147,4 @@ export function TaskQueuePage() {
)}
</div>
)
}
}
@@ -39,6 +39,9 @@ export default function ReaderSourcesPage() {
const [debugKey, setDebugKey] = useState('')
const [debugLogs, setDebugLogs] = useState<string[] | null>(null)
const [debugging, setDebugging] = useState(false)
const [batchKey, setBatchKey] = useState('')
const [batchTesting, setBatchTesting] = useState(false)
const [batchResults, setBatchResults] = useState<Record<string, { ok: boolean; message: string }>>({})
// 登录面板:记录正在登录的书源
const [loginSource, setLoginSource] = useState<ReaderSource | null>(null)
const [loggedInIds, setLoggedInIds] = useState<Record<string, boolean>>({})
@@ -122,6 +125,30 @@ export default function ReaderSourcesPage() {
}
}
const testEnabledSources = async () => {
const key = batchKey.trim()
const enabled = (sources ?? []).filter((source) => source.enabled)
if (!key) { toast.error('先填写用于检测的书名关键词'); return }
if (!enabled.length) { toast.error('没有已启用的书源'); return }
setBatchTesting(true)
setBatchResults({})
for (let offset = 0; offset < enabled.length; offset += 3) {
const batch = enabled.slice(offset, offset + 3)
const results = await Promise.all(batch.map(async (source) => {
try {
const logs = await readerAPI.smokeSource(source.id, key)
const error = logs.find((line) => line.startsWith('[错误]'))
return [source.id, { ok: !error, message: error ? error.replace(/https?:\/\/[^\s"'<>]+/gi, '[URL]') : '搜索、目录和正文链路通过' }] as const
} catch (error) {
const message = ((error as { response?: { data?: { error?: string } } })?.response?.data?.error || '检测超时或请求失败').replace(/https?:\/\/[^\s"'<>]+/gi, '[URL]')
return [source.id, { ok: false, message }] as const
}
}))
setBatchResults((current) => ({ ...current, ...Object.fromEntries(results) }))
}
setBatchTesting(false)
}
return (
<div className="mx-auto min-h-[100dvh] w-full max-w-4xl px-4 pb-16 pt-4 sm:px-6">
<div className="flex items-center gap-3">
@@ -205,6 +232,14 @@ export default function ReaderSourcesPage() {
</div>
)}
<div className="mt-4 flex flex-wrap items-center gap-2 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-3">
<input value={batchKey} onChange={(event) => setBatchKey(event.target.value)} placeholder="全链路检测关键词,例如:斗破苍穹" className="min-w-48 flex-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-xs text-[var(--app-text)] outline-none" />
<button type="button" className="btn-outline text-xs" disabled={batchTesting || !sources?.some((source) => source.enabled)} onClick={() => void testEnabledSources()}>
{batchTesting ? <><Loader2 size={12} className="mr-1 inline animate-spin" />检测中</> : '检测全部已启用书源'}
</button>
<span className="text-[10px] text-[var(--app-muted)]">每源检查搜索、详情、目录和一章正文,并发最多 3 个</span>
</div>
<div className="mt-6 space-y-2">
{sources === null && (
<div className="flex items-center justify-center py-24 text-[var(--app-muted)]">
@@ -228,6 +263,7 @@ export default function ReaderSourcesPage() {
{src.group && <span className="truncate text-2xs font-normal text-[var(--app-muted)]">{src.group}</span>}
</p>
<p className="mt-0.5 truncate text-xs text-[var(--app-muted)]">{hostOf(src.source_url)}</p>
{batchResults[src.id] && <p className={`mt-1 truncate text-[10px] ${batchResults[src.id].ok ? 'text-emerald-500' : 'text-red-400'}`}>{batchResults[src.id].message}</p>}
</div>
{/* 登录入口:仅对声明了 loginUrl/loginUi 的书源显示 */}
{src.has_login && (
+8
View File
@@ -21,10 +21,18 @@ export const aboutSettingsGroup: SettingGroup = {
items: [],
}
export const backupSettingsGroup: SettingGroup = {
key: 'backup',
label: '备份',
description: '创建、下载、恢复和清理数据库备份',
items: [],
}
export const GROUPS: SettingGroup[] = [
generalSettingsGroup,
deviceNotifySettingsGroup,
databaseSettingsGroup,
backupSettingsGroup,
apiConfigsSettingsGroup,
recognitionWordsSettingsGroup,
adultSettingsGroup,
+3 -5
View File
@@ -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)
+2
View File
@@ -1,5 +1,7 @@
export interface Media {
id: string
availability_state?: 'available' | 'missing' | string
missing_since?: string
library_id: string
library_root_id?: string
library_name?: string
+29 -83
View File
@@ -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
+107
View File
@@ -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)
})
+106
View File
@@ -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
},
}
}