diff --git a/internal/handler/media.go b/internal/handler/media.go
index da97844..c3e2bea 100644
--- a/internal/handler/media.go
+++ b/internal/handler/media.go
@@ -556,19 +556,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,
})
diff --git a/internal/handler/routes_authenticated_features.go b/internal/handler/routes_authenticated_features.go
index 2769c1a..e96892b 100644
--- a/internal/handler/routes_authenticated_features.go
+++ b/internal/handler/routes_authenticated_features.go
@@ -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))
}
diff --git a/internal/handler/ws.go b/internal/handler/ws.go
index 5ba80ce..35014a9 100644
--- a/internal/handler/ws.go
+++ b/internal/handler/ws.go
@@ -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
diff --git a/internal/handler/ws_test.go b/internal/handler/ws_test.go
new file mode 100644
index 0000000..9537a13
--- /dev/null
+++ b/internal/handler/ws_test.go
@@ -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)
+ }
+}
diff --git a/internal/repository/media_repository.go b/internal/repository/media_repository.go
index 091844f..37f9a67 100644
--- a/internal/repository/media_repository.go
+++ b/internal/repository/media_repository.go
@@ -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
diff --git a/internal/repository/media_repository_upsert.go b/internal/repository/media_repository_upsert.go
index 84b0554..9db85a1 100644
--- a/internal/repository/media_repository_upsert.go
+++ b/internal/repository/media_repository_upsert.go
@@ -96,15 +96,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)
}
}
}
diff --git a/internal/repository/opensearch.go b/internal/repository/opensearch.go
index 9b8ecdc..6d931ed 100644
--- a/internal/repository/opensearch.go
+++ b/internal/repository/opensearch.go
@@ -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)
diff --git a/internal/repository/opensearch_test.go b/internal/repository/opensearch_test.go
index c3bd806..309e33c 100644
--- a/internal/repository/opensearch_test.go
+++ b/internal/repository/opensearch_test.go
@@ -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)
+ }
+}
diff --git a/internal/service/media.go b/internal/service/media.go
index ffa89b5..b844850 100644
--- a/internal/service/media.go
+++ b/internal/service/media.go
@@ -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 {
diff --git a/internal/service/media_listing.go b/internal/service/media_listing.go
index 077a29f..cb20361 100644
--- a/internal/service/media_listing.go
+++ b/internal/service/media_listing.go
@@ -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
}
diff --git a/internal/service/media_sorted_page.go b/internal/service/media_sorted_page.go
new file mode 100644
index 0000000..bd98c59
--- /dev/null
+++ b/internal/service/media_sorted_page.go
@@ -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
+}
diff --git a/internal/service/media_sorted_page_test.go b/internal/service/media_sorted_page_test.go
new file mode 100644
index 0000000..5aecde0
--- /dev/null
+++ b/internal/service/media_sorted_page_test.go
@@ -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)
+ }
+ })
+}
diff --git a/web/src/components/RequireAuth.tsx b/web/src/components/RequireAuth.tsx
index 8880828..24671bb 100644
--- a/web/src/components/RequireAuth.tsx
+++ b/web/src/components/RequireAuth.tsx
@@ -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
}
- return <>{children}>
+ return {children}
}
// Route guard: only allows users with role === "admin".
diff --git a/web/src/hooks/useWebSocket.ts b/web/src/hooks/useWebSocket.ts
index c9e202d..4fbc04a 100644
--- a/web/src/hooks/useWebSocket.ts
+++ b/web/src/hooks/useWebSocket.ts
@@ -15,6 +15,7 @@ const SLOW_RECONNECT_INTERVAL = 60_000
export function useWebSocket(onEvent: (topic: string, payload: unknown) => void) {
const ref = useRef(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])
}
diff --git a/web/src/pages/useLibraryData.ts b/web/src/pages/useLibraryData.ts
index 5819dad..eb60cf7 100644
--- a/web/src/pages/useLibraryData.ts
+++ b/web/src/pages/useLibraryData.ts
@@ -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)
diff --git a/web/src/utils/libraryCache.ts b/web/src/utils/libraryCache.ts
index de3649c..b5a1fc4 100644
--- a/web/src/utils/libraryCache.ts
+++ b/web/src/utils/libraryCache.ts
@@ -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 | null = null
-
-const byId = new Map()
-const byIdInflight = new Map>()
-
-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 {
- 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 {
- 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
diff --git a/web/src/utils/libraryCacheState.test.ts b/web/src/utils/libraryCacheState.test.ts
new file mode 100644
index 0000000..f3dd14d
--- /dev/null
+++ b/web/src/utils/libraryCacheState.test.ts
@@ -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() {
+ let resolve!: (value: T) => void
+ const promise = new Promise((done) => { resolve = done })
+ return { promise, resolve }
+}
+
+test('list requests share a flight and populate the detail cache', async () => {
+ const request = deferred()
+ 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()
+ const fresh = deferred()
+ 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()
+ const fresh = deferred()
+ 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()
+ 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)
+})
diff --git a/web/src/utils/libraryCacheState.ts b/web/src/utils/libraryCacheState.ts
new file mode 100644
index 0000000..19c3f34
--- /dev/null
+++ b/web/src/utils/libraryCacheState.ts
@@ -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
+ get: (id: string) => Promise
+ readScope: () => string
+}) {
+ let scope = readScope()
+ let generation = 0
+ let listData: Library[] | null = null
+ let listAt = 0
+ let listInflight: Promise | null = null
+ const byId = new Map()
+ const byIdInflight = new Map>()
+
+ 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 {
+ 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 {
+ 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
+ },
+ }
+}